diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000..76ce58fccf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,61 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +## Description + + + +## Expected Behavior + + + +## Actual Behaviour + + + +## Steps to Reproduce + + + +1. +2. +3. +4. + +## Animated Gif/Screenshot + + + +## Context + + + +## Severity + + +## Your Environment + + +* Version used: +* Browser name and version: +* Operating System and version (desktop or mobile): +* OFN Platform instance where you discovered the bug, and which version of the software they are using. + +## Possible Fix + diff --git a/.github/ISSUE_TEMPLATE/feature-template.md b/.github/ISSUE_TEMPLATE/feature-template.md new file mode 100644 index 0000000000..9fb6cc1741 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-template.md @@ -0,0 +1,35 @@ +--- +name: Feature template +about: Create feature epics that detail the larger feature or functionality to be + delivered. +title: '' +labels: '' +assignees: '' + +--- + +## What is the problem we are solving + + +## Success factors = expected outcome + + +## Useful information for inception + + +## Link to the "Product Development - Backlog" item in Discourse + + Add a custom footer + Pages 70 +Home +Development environment setup + +macOS (Sierra, HighSierra and Mojave) +OS X (El Capitan) +OS X (Mavericks) +Ubuntu +On Heroku +Rubocop +General guidelines + +Spree Commerce customisation diff --git a/.github/ISSUE_TEMPLATE/story-template.md b/.github/ISSUE_TEMPLATE/story-template.md new file mode 100644 index 0000000000..48a1c33493 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/story-template.md @@ -0,0 +1,18 @@ +--- +name: Story template +about: Create stories that are small chunks of work that devs will pick up and deliver +title: '' +labels: '' +assignees: '' + +--- + +**## Description** + + +**## Acceptance Criteria** + diff --git a/.rubocop_styleguide.yml b/.rubocop_styleguide.yml index 13214b0aed..accf9e6314 100644 --- a/.rubocop_styleguide.yml +++ b/.rubocop_styleguide.yml @@ -194,6 +194,8 @@ Metrics/BlockNesting: Metrics/ClassLength: Max: 100 + Exclude: + - engines/order_management/app/services/order_management/reports/enterprise_fee_summary/scope.rb Metrics/ModuleLength: Max: 100 @@ -203,6 +205,8 @@ Metrics/CyclomaticComplexity: Metrics/MethodLength: Max: 10 + Exclude: + - engines/order_management/app/services/order_management/reports/enterprise_fee_summary/scope.rb Metrics/ParameterLists: Max: 5 diff --git a/Gemfile b/Gemfile index 2aa4f83d64..e021395613 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,7 @@ gem 'i18n-js', '~> 3.1.0' # Patched version. See http://rubysec.com/advisories/CVE-2015-5312/. gem 'nokogiri', '>= 1.6.7.1' +gem "order_management", path: "./engines/order_management" gem 'web', path: './engines/web' gem 'pg' @@ -26,13 +27,13 @@ gem 'spree_auth_devise', github: 'spree/spree_auth_devise', branch: '2-0-stable' # - Pass customer email and phone number to PayPal (merged to upstream master) # - Change type of password from string to password to hide it in the form gem 'spree_paypal_express', github: "openfoodfoundation/better_spree_paypal_express", branch: "2-0-stable" -gem 'stripe', '~> 3.3.2' +gem 'stripe', '~> 4.5.0' # We need at least this version to have Digicert's root certificate # which is needed for Pin Payments (and possibly others). gem 'activemerchant', '~> 1.78' gem 'oauth2', '~> 1.4.1' # Used for Stripe Connect -gem 'jwt', '~> 1.5' +gem 'jwt', '~> 2.1' gem 'delayed_job_active_record' gem 'daemons' diff --git a/Gemfile.lock b/Gemfile.lock index 4f104abd9b..f42f7e1595 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -118,6 +118,11 @@ GIT activemodel (>= 3.0) railties (>= 3.0) +PATH + remote: engines/order_management + specs: + order_management (0.0.1) + PATH remote: engines/web specs: @@ -240,6 +245,7 @@ GEM compass (~> 1.0.0) sass-rails (< 5.1) sprockets (< 4.0) + connection_pool (2.2.2) crack (0.4.3) safe_yaml (~> 1.0.0) css_parser (1.6.0) @@ -501,7 +507,7 @@ GEM json_spec (1.1.5) multi_json (~> 1.0) rspec (>= 2.0, < 4.0) - jwt (1.5.6) + jwt (2.1.0) kaminari (0.14.1) actionpack (>= 3.0.0) activesupport (>= 3.0.0) @@ -532,6 +538,8 @@ GEM multi_xml (0.6.0) multipart-post (2.0.0) nenv (0.3.0) + net-http-persistent (3.0.0) + connection_pool (~> 2.2) nokogiri (1.6.8.1) mini_portile2 (~> 2.1.0) notiffany (0.1.1) @@ -623,7 +631,7 @@ GEM trollop (~> 2.1) rdoc (3.12.2) json (~> 1.4) - redcarpet (3.2.3) + redcarpet (3.4.0) ref (2.0.0) request_store (1.4.1) rack (>= 1.4) @@ -711,8 +719,9 @@ GEM tilt (~> 1.1, != 1.3.0) state_machine (1.2.0) stringex (1.5.1) - stripe (3.3.2) - faraday (~> 0.9) + stripe (4.5.0) + faraday (~> 0.13) + net-http-persistent (~> 3.0) therubyracer (0.12.0) libv8 (~> 3.16.14.0) ref @@ -808,7 +817,7 @@ DEPENDENCIES jquery-migrate-rails jquery-rails (= 3.0.0) json_spec (~> 1.1.4) - jwt (~> 1.5) + jwt (~> 2.1) knapsack letter_opener (>= 1.4.1) listen (= 3.0.8) @@ -817,6 +826,7 @@ DEPENDENCIES oauth2 (~> 1.4.1) ofn-qz! oj + order_management! paper_trail (~> 5.2.3) paperclip (~> 3.4.1) pg @@ -848,7 +858,7 @@ DEPENDENCIES spree_paypal_express! spring (= 1.7.2) spring-commands-rspec - stripe (~> 3.3.2) + stripe (~> 4.5.0) therubyracer (= 0.12.0) timecop truncate_html diff --git a/app/assets/javascripts/admin/subscriptions/controllers/subscription_controller.js.coffee b/app/assets/javascripts/admin/subscriptions/controllers/subscription_controller.js.coffee index 3b26866125..aa635da8cc 100644 --- a/app/assets/javascripts/admin/subscriptions/controllers/subscription_controller.js.coffee +++ b/app/assets/javascripts/admin/subscriptions/controllers/subscription_controller.js.coffee @@ -6,7 +6,11 @@ angular.module("admin.subscriptions").controller "SubscriptionController", ($sco $scope.schedules = Schedules.all $scope.paymentMethods = PaymentMethods.all $scope.shippingMethods = ShippingMethods.all - $scope.distributor_id = $scope.subscription.shop_id # variant selector requires distributor_id + + # Variant selector requires these + $scope.distributor_id = $scope.subscription.shop_id + $scope.eligible_for_subscriptions = true + $scope.view = if $scope.subscription.id? then 'review' else 'details' $scope.nextCallbacks = {} $scope.backCallbacks = {} diff --git a/app/assets/javascripts/admin/utils/directives/variant_autocomplete.js.coffee b/app/assets/javascripts/admin/utils/directives/variant_autocomplete.js.coffee index c325c568ec..c8c462dadb 100644 --- a/app/assets/javascripts/admin/utils/directives/variant_autocomplete.js.coffee +++ b/app/assets/javascripts/admin/utils/directives/variant_autocomplete.js.coffee @@ -22,6 +22,7 @@ angular.module("admin.utils").directive "variantAutocomplete", ($timeout) -> q: term distributor_id: scope.distributor_id order_cycle_id: scope.order_cycle_id + eligible_for_subscriptions: scope.eligible_for_subscriptions results: (data, page) -> window.variants = data # this is how spree auto complete JS code picks up variants results: data diff --git a/app/assets/javascripts/darkswarm/filters/dates.js.coffee b/app/assets/javascripts/darkswarm/filters/dates.js.coffee index 7b5dd861d5..91efe89d1e 100644 --- a/app/assets/javascripts/darkswarm/filters/dates.js.coffee +++ b/app/assets/javascripts/darkswarm/filters/dates.js.coffee @@ -4,7 +4,7 @@ Darkswarm.filter "date_in_words", -> Darkswarm.filter "sensible_timeframe", (date_in_wordsFilter)-> (date) -> - if moment().add('days', 2) < moment(date) + if moment().add(2, 'days') < moment(date) t 'orders_open' else t('closing') + date_in_wordsFilter(date) diff --git a/app/assets/stylesheets/admin/pages/subscription_form.css.scss b/app/assets/stylesheets/admin/pages/subscription_form.css.scss new file mode 100644 index 0000000000..b892c4f17e --- /dev/null +++ b/app/assets/stylesheets/admin/pages/subscription_form.css.scss @@ -0,0 +1,7 @@ +@import '../variables'; + +.admin-subscription-form-subscription-line-items { + .not-in-open-and-upcoming-order-cycles-warning { + color: $warning-red; + } +} diff --git a/app/assets/stylesheets/admin/pages/subscription_review.css.scss b/app/assets/stylesheets/admin/pages/subscription_review.css.scss new file mode 100644 index 0000000000..76008afc0f --- /dev/null +++ b/app/assets/stylesheets/admin/pages/subscription_review.css.scss @@ -0,0 +1,7 @@ +@import '../variables'; + +.admin-subscription-review-subscription-line-items { + .not-in-open-and-upcoming-order-cycles-warning { + color: $warning-red; + } +} diff --git a/app/controllers/admin/subscription_line_items_controller.rb b/app/controllers/admin/subscription_line_items_controller.rb index d981b71fe1..5267374539 100644 --- a/app/controllers/admin/subscription_line_items_controller.rb +++ b/app/controllers/admin/subscription_line_items_controller.rb @@ -13,7 +13,8 @@ module Admin def build @subscription_line_item.assign_attributes(params[:subscription_line_item]) @subscription_line_item.price_estimate = price_estimate - render json: @subscription_line_item, serializer: Api::Admin::SubscriptionLineItemSerializer + render json: @subscription_line_item, serializer: Api::Admin::SubscriptionLineItemSerializer, + shop: @shop, schedule: @schedule end private @@ -26,7 +27,7 @@ module Admin @shop = Enterprise.managed_by(spree_current_user).find_by_id(params[:shop_id]) @schedule = permissions.editable_schedules.find_by_id(params[:schedule_id]) @order_cycle = @schedule.andand.current_or_next_order_cycle - @variant = Spree::Variant.stockable_by(@shop).find_by_id(params[:subscription_line_item][:variant_id]) + @variant = variant_if_eligible(params[:subscription_line_item][:variant_id]) if @shop.present? end def new_actions @@ -50,5 +51,9 @@ module Admin OpenFoodNetwork::ScopeVariantToHub.new(@shop).scope(@variant) @variant.price + fee_calculator.indexed_fees_for(@variant) end + + def variant_if_eligible(variant_id) + SubscriptionVariantsService.eligible_variants(@shop).find_by_id(variant_id) + end end end diff --git a/app/controllers/spree/admin/invoices_controller.rb b/app/controllers/spree/admin/invoices_controller.rb index 230d01322b..710fda1a3a 100644 --- a/app/controllers/spree/admin/invoices_controller.rb +++ b/app/controllers/spree/admin/invoices_controller.rb @@ -2,6 +2,7 @@ module Spree module Admin class InvoicesController < Spree::Admin::BaseController respond_to :json + authorize_resource class: false def create invoice_service = BulkInvoiceService.new diff --git a/app/controllers/spree/admin/reports/enterprise_fee_summaries_controller.rb b/app/controllers/spree/admin/reports/enterprise_fee_summaries_controller.rb new file mode 100644 index 0000000000..e6b750b904 --- /dev/null +++ b/app/controllers/spree/admin/reports/enterprise_fee_summaries_controller.rb @@ -0,0 +1,66 @@ +module Spree + module Admin + module Reports + class EnterpriseFeeSummariesController < BaseController + before_filter :load_report_parameters + before_filter :load_permissions + + def new; end + + def create + return respond_to_invalid_parameters unless @report_parameters.valid? + + @report_parameters.authorize!(@permissions) + + @report = report_klass::ReportService.new(@permissions, @report_parameters) + renderer.render(self) + rescue ::Reports::Authorizer::ParameterNotAllowedError => e + flash[:error] = e.message + render_report_form + end + + private + + def respond_to_invalid_parameters + flash[:error] = I18n.t("invalid_filter_parameters", scope: i18n_scope) + render_report_form + end + + def i18n_scope + "order_management.reports.enterprise_fee_summary" + end + + def render_report_form + render action: :new + end + + def report_klass + OrderManagement::Reports::EnterpriseFeeSummary + end + + def load_report_parameters + @report_parameters = report_klass::Parameters.new(params[:report] || {}) + end + + def load_permissions + @permissions = report_klass::Permissions.new(spree_current_user) + end + + def report_renderer_klass + case params[:report_format] + when "csv" + report_klass::Renderers::CsvRenderer + when nil, "", "html" + report_klass::Renderers::HtmlRenderer + else + raise Reports::UnsupportedReportFormatException + end + end + + def renderer + @renderer ||= report_renderer_klass.new(@report) + end + end + end + end +end diff --git a/app/controllers/spree/admin/reports_controller_decorator.rb b/app/controllers/spree/admin/reports_controller_decorator.rb index 657c6b6bb8..ef9d906a74 100644 --- a/app/controllers/spree/admin/reports_controller_decorator.rb +++ b/app/controllers/spree/admin/reports_controller_decorator.rb @@ -1,4 +1,6 @@ require 'csv' + +require 'open_food_network/reports/list' require 'open_food_network/order_and_distributor_report' require 'open_food_network/products_and_inventory_report' require 'open_food_network/lettuce_share_report' @@ -24,35 +26,7 @@ Spree::Admin::ReportsController.class_eval do before_filter :load_data, only: [:customers, :products_and_inventory, :order_cycle_management, :packing] def report_types - { - orders_and_fulfillment: [ - [I18n.t('admin.reports.supplier_totals'), :order_cycle_supplier_totals], - [I18n.t('admin.reports.supplier_totals_by_distributor'), :order_cycle_supplier_totals_by_distributor], - [I18n.t('admin.reports.totals_by_supplier'), :order_cycle_distributor_totals_by_supplier], - [I18n.t('admin.reports.customer_totals'), :order_cycle_customer_totals] - ], - products_and_inventory: [ - [I18n.t('admin.reports.all_products'), :all_products], - [I18n.t('admin.reports.inventory'), :inventory], - [I18n.t('admin.reports.lettuce_share'), :lettuce_share] - ], - customers: [ - [I18n.t('admin.reports.mailing_list'), :mailing_list], - [I18n.t('admin.reports.addresses'), :addresses] - ], - order_cycle_management: [ - [I18n.t('admin.reports.payment_methods'), :payment_methods], - [I18n.t('admin.reports.delivery'), :delivery] - ], - sales_tax: [ - [I18n.t('admin.reports.tax_types'), :tax_types], - [I18n.t('admin.reports.tax_rates'), :tax_rates] - ], - packing: [ - [I18n.t('admin.reports.pack_by_customer'), :pack_by_customer], - [I18n.t('admin.reports.pack_by_supplier'), :pack_by_supplier] - ] - } + OpenFoodNetwork::Reports::List.all end # Override spree reports list. @@ -275,6 +249,7 @@ Spree::Admin::ReportsController.class_eval do :products_and_inventory, :sales_total, :users_and_enterprises, + :enterprise_fee_summary, :order_cycle_management, :sales_tax, :xero_invoices, @@ -295,7 +270,13 @@ Spree::Admin::ReportsController.class_eval do locals: { report_types: report_types[report] } ).html_safe end - { name: name, description: description } + { name: name, url: url_for_report(report), description: description } + end + + def url_for_report(report) + public_send("#{report}_admin_reports_url".to_sym) + rescue NoMethodError + url_for([:new, :admin, :reports, report.to_s.singularize]) end def timestamp diff --git a/app/models/feature_flags.rb b/app/models/feature_flags.rb index aaba0f4472..096ef44fcc 100644 --- a/app/models/feature_flags.rb +++ b/app/models/feature_flags.rb @@ -14,6 +14,13 @@ class FeatureFlags superadmin? end + # Checks whether the "Enterprise Fee Summary" is enabled for the specified user + # + # @return [Boolean] + def enterprise_fee_summary_enabled? + superadmin? + end + private attr_reader :user diff --git a/app/models/product_import/entry_validator.rb b/app/models/product_import/entry_validator.rb index ce4907c045..090c55e01d 100644 --- a/app/models/product_import/entry_validator.rb +++ b/app/models/product_import/entry_validator.rb @@ -193,7 +193,7 @@ module ProductImport products.flat_map(&:variants).each do |existing_variant| unit_scale = existing_variant.product.variant_unit_scale unscaled_units = entry.unscaled_units || 0 - entry.unit_value = unscaled_units * unit_scale + entry.unit_value = unscaled_units * unit_scale unless unit_scale.nil? if entry_matches_existing_variant?(entry, existing_variant) variant_override = create_inventory_item(entry, existing_variant) diff --git a/app/models/spree/ability_decorator.rb b/app/models/spree/ability_decorator.rb index 45f3dfe157..7fa193680c 100644 --- a/app/models/spree/ability_decorator.rb +++ b/app/models/spree/ability_decorator.rb @@ -182,7 +182,10 @@ class AbilityDecorator can [:admin, :index, :guide, :import, :save, :save_data, :validate_data, :reset_absent_products], ProductImport::ProductImporter # Reports page - can [:admin, :index, :customers, :orders_and_distributors, :group_buys, :bulk_coop, :payments, :orders_and_fulfillment, :products_and_inventory, :order_cycle_management, :packing], :report + can [:admin, :index, :customers, :orders_and_distributors, :group_buys, :bulk_coop, :payments, + :orders_and_fulfillment, :products_and_inventory, :order_cycle_management, :packing], + :report + add_enterprise_fee_summary_abilities(user) end def add_order_cycle_management_abilities(user) @@ -208,9 +211,10 @@ class AbilityDecorator # during the order creation process from the admin backend order.distributor.nil? || user.enterprises.include?(order.distributor) || order.order_cycle.andand.coordinated_by?(user) end - can [:admin, :bulk_management, :managed, :bulk_invoice], Spree::Order do + can [:admin, :bulk_management, :managed], Spree::Order do user.admin? || user.enterprises.any?(&:is_distributor) end + can [:admin, :create, :show, :poll], :invoice can [:admin, :visible], Enterprise can [:admin, :index, :create, :update, :destroy], :line_item can [:admin, :index, :create], Spree::LineItem @@ -254,7 +258,10 @@ class AbilityDecorator end # Reports page - can [:admin, :index, :customers, :group_buys, :bulk_coop, :sales_tax, :payments, :orders_and_distributors, :orders_and_fulfillment, :products_and_inventory, :order_cycle_management, :xero_invoices], :report + can [:admin, :index, :customers, :group_buys, :bulk_coop, :sales_tax, :payments, + :orders_and_distributors, :orders_and_fulfillment, :products_and_inventory, + :order_cycle_management, :xero_invoices], :report + add_enterprise_fee_summary_abilities(user) can [:create], Customer can [:admin, :index, :update, :destroy, :show], Customer, enterprise_id: Enterprise.managed_by(user).pluck(:id) @@ -277,6 +284,16 @@ class AbilityDecorator user.enterprises.include? enterprise_relationship.parent end end + + def add_enterprise_fee_summary_abilities(user) + feature_enabled = FeatureFlags.new(user).enterprise_fee_summary_enabled? + return unless feature_enabled + + # Reveal the report link in spree/admin/reports#index + can [:enterprise_fee_summary], :report + # Allow direct access to the report resource + can [:admin, :new, :create], :enterprise_fee_summary + end end Spree::Ability.register_ability(AbilityDecorator) diff --git a/app/models/spree/payment_method_decorator.rb b/app/models/spree/payment_method_decorator.rb index 77b17e0f60..1d3a502c13 100644 --- a/app/models/spree/payment_method_decorator.rb +++ b/app/models/spree/payment_method_decorator.rb @@ -25,6 +25,11 @@ Spree::PaymentMethod.class_eval do end } + scope :for_distributors, ->(distributors) { + non_unique_matches = unscoped.joins(:distributors).where(enterprises: { id: distributors }) + where(id: non_unique_matches.map(&:id)) + } + scope :for_distributor, lambda { |distributor| joins(:distributors). where('enterprises.id = ?', distributor) diff --git a/app/models/spree/shipping_method_decorator.rb b/app/models/spree/shipping_method_decorator.rb index dd4c7d71de..071802020b 100644 --- a/app/models/spree/shipping_method_decorator.rb +++ b/app/models/spree/shipping_method_decorator.rb @@ -20,6 +20,10 @@ Spree::ShippingMethod.class_eval do end } + scope :for_distributors, ->(distributors) { + non_unique_matches = unscoped.joins(:distributors).where(enterprises: { id: distributors }) + where(id: non_unique_matches.map(&:id)) + } scope :for_distributor, lambda { |distributor| joins(:distributors). where('enterprises.id = ?', distributor) diff --git a/app/serializers/api/admin/subscription_line_item_serializer.rb b/app/serializers/api/admin/subscription_line_item_serializer.rb index 23f4c6bc52..34bc00c6c0 100644 --- a/app/serializers/api/admin/subscription_line_item_serializer.rb +++ b/app/serializers/api/admin/subscription_line_item_serializer.rb @@ -1,7 +1,8 @@ module Api module Admin class SubscriptionLineItemSerializer < ActiveModel::Serializer - attributes :id, :variant_id, :quantity, :description, :price_estimate + attributes :id, :variant_id, :quantity, :description, :price_estimate, + :in_open_and_upcoming_order_cycles def description "#{object.variant.product.name} - #{object.variant.full_name}" @@ -10,6 +11,22 @@ module Api def price_estimate object.price_estimate.andand.to_f || "?" end + + def in_open_and_upcoming_order_cycles + SubscriptionVariantsService.in_open_and_upcoming_order_cycles?(option_or_assigned_shop, + option_or_assigned_schedule, + object.variant) + end + + private + + def option_or_assigned_shop + @options[:shop] || object.subscription.andand.shop + end + + def option_or_assigned_schedule + @options[:schedule] || object.subscription.andand.schedule + end end end end diff --git a/app/services/subscription_validator.rb b/app/services/subscription_validator.rb index 33fc2baf77..051033dd9f 100644 --- a/app/services/subscription_validator.rb +++ b/app/services/subscription_validator.rb @@ -97,15 +97,12 @@ class SubscriptionValidator errors.add(:subscription_line_items, :not_available, name: name) end - # TODO: Extract this into a separate class def available_variant_ids - @available_variant_ids ||= - Spree::Variant.joins(exchanges: { order_cycle: :schedules }) - .where(id: subscription_line_items.map(&:variant_id)) - .where(schedules: { id: schedule }, exchanges: { incoming: false, receiver_id: shop }) - .merge(OrderCycle.not_closed) - .select('DISTINCT spree_variants.id') - .pluck(:id) + return @available_variant_ids if @available_variant_ids.present? + + subscription_variant_ids = subscription_line_items.map(&:variant_id) + @available_variant_ids = SubscriptionVariantsService.eligible_variants(shop) + .where(id: subscription_variant_ids).pluck(:id) end def build_msg_from(k, msg) diff --git a/app/services/subscription_variants_service.rb b/app/services/subscription_variants_service.rb new file mode 100644 index 0000000000..855c200303 --- /dev/null +++ b/app/services/subscription_variants_service.rb @@ -0,0 +1,39 @@ +class SubscriptionVariantsService + # Includes the following variants: + # - Variants of permitted producers + # - Variants of hub + # - Variants that are in outgoing exchanges where the hub is receiver + def self.eligible_variants(distributor) + variant_conditions = ["spree_products.supplier_id IN (?)", permitted_producer_ids(distributor)] + exchange_variant_ids = outgoing_exchange_variant_ids(distributor) + if exchange_variant_ids.present? + variant_conditions[0] << " OR spree_variants.id IN (?)" + variant_conditions << exchange_variant_ids + end + + Spree::Variant.joins(:product).where(is_master: false).where(*variant_conditions) + end + + def self.in_open_and_upcoming_order_cycles?(distributor, schedule, variant) + scope = ExchangeVariant.joins(exchange: { order_cycle: :schedules }) + .where(variant_id: variant, exchanges: { incoming: false, receiver_id: distributor }) + .merge(OrderCycle.not_closed) + scope = scope.where(schedules: { id: schedule }) + scope.any? + end + + def self.permitted_producer_ids(distributor) + other_permitted_producer_ids = EnterpriseRelationship.joins(:parent) + .permitting(distributor).with_permission(:add_to_order_cycle) + .merge(Enterprise.is_primary_producer) + .pluck(:parent_id) + + other_permitted_producer_ids | [distributor.id] + end + + def self.outgoing_exchange_variant_ids(distributor) + ExchangeVariant.select("DISTINCT exchange_variants.variant_id").joins(:exchange) + .where(exchanges: { incoming: false, receiver_id: distributor.id }) + .pluck(:variant_id) + end +end diff --git a/app/validators/date_time_string_validator.rb b/app/validators/date_time_string_validator.rb new file mode 100644 index 0000000000..f1a4eccbfb --- /dev/null +++ b/app/validators/date_time_string_validator.rb @@ -0,0 +1,63 @@ +# Validates a datetime string with relaxed rules +# +# This uses ActiveSupport::TimeZone.parse behind the scenes. +# +# https://api.rubyonrails.org/classes/ActiveSupport/TimeZone.html#method-i-parse +# +# === Example +# +# class Post +# include ActiveModel::Validations +# +# attr_accessor :published_at +# validates :published_at, date_time_string: true +# end +# +# post = Post.new +# +# post.published_at = nil +# post.valid? # => true +# +# post.published_at = "" +# post.valid? # => true +# +# post.published_at = [] +# post.valid? # => false +# post.errors[:published_at] # => ["must be a string"] +# +# post.published_at = 1 +# post.valid? # => false +# post.errors[:published_at] # => ["must be a string"] +# +# post.published_at = "2018-09-20 01:02:00 +10:00" +# post.valid? # => true +# +# post.published_at = "Not Valid" +# post.valid? # => false +# post.errors[:published_at] # => ["must be valid"] +class DateTimeStringValidator < ActiveModel::EachValidator + NOT_STRING_ERROR = I18n.t("validators.date_time_string_validator.not_string_error") + INVALID_FORMAT_ERROR = I18n.t("validators.date_time_string_validator.invalid_format_error") + + def validate_each(record, attribute, value) + return if value.nil? || value == "" + + validate_attribute_is_string(record, attribute, value) + validate_attribute_is_datetime_string(record, attribute, value) + end + + protected + + def validate_attribute_is_string(record, attribute, value) + return if value.is_a?(String) + + record.errors.add(attribute, NOT_STRING_ERROR) + end + + def validate_attribute_is_datetime_string(record, attribute, value) + return unless value.is_a?(String) + + datetime = Time.zone.parse(value) + record.errors.add(attribute, INVALID_FORMAT_ERROR) if datetime.blank? + end +end diff --git a/app/validators/integer_array_validator.rb b/app/validators/integer_array_validator.rb new file mode 100644 index 0000000000..27042c972b --- /dev/null +++ b/app/validators/integer_array_validator.rb @@ -0,0 +1,63 @@ +# Validates an integer array +# +# This uses Integer() behind the scenes. +# +# === Example +# +# class Post +# include ActiveModel::Validations +# +# attr_accessor :related_post_ids +# validates :related_post_ids, integer_array: true +# end +# +# post = Post.new +# +# post.related_post_ids = nil +# post.valid? # => true +# +# post.related_post_ids = [] +# post.valid? # => true +# +# post.related_post_ids = 1 +# post.valid? # => false +# post.errors[:related_post_ids] # => ["must be an array"] +# +# post.related_post_ids = [1, 2, 3] +# post.valid? # => true +# +# post.related_post_ids = ["1", "2", "3"] +# post.valid? # => true +# +# post.related_post_ids = [1, "2", "Not Integer", 3] +# post.valid? # => false +# post.errors[:related_post_ids] # => ["must contain only valid integers"] +class IntegerArrayValidator < ActiveModel::EachValidator + NOT_ARRAY_ERROR = I18n.t("validators.integer_array_validator.not_array_error") + INVALID_ELEMENT_ERROR = I18n.t("validators.integer_array_validator.invalid_element_error") + + def validate_each(record, attribute, value) + return if value.nil? + + validate_attribute_is_array(record, attribute, value) + validate_attribute_elements_are_integer(record, attribute, value) + end + + protected + + def validate_attribute_is_array(record, attribute, value) + return if value.is_a?(Array) + + record.errors.add(attribute, NOT_ARRAY_ERROR) + end + + def validate_attribute_elements_are_integer(record, attribute, array) + return unless array.is_a?(Array) + + array.each do |element| + Integer(element) + end + rescue ArgumentError + record.errors.add(attribute, INVALID_ELEMENT_ERROR) + end +end diff --git a/app/views/admin/subscriptions/_review.html.haml b/app/views/admin/subscriptions/_review.html.haml index e1591e6210..7e97bd02aa 100644 --- a/app/views/admin/subscriptions/_review.html.haml +++ b/app/views/admin/subscriptions/_review.html.haml @@ -56,7 +56,7 @@ %input#edit-products{ type: "button", value: t(:edit), ng: { click: "setView('products')" } } .row .seven.columns.alpha.omega - %table#subscription-line-items + %table#subscription-line-items.admin-subscription-review-subscription-line-items %colgroup %col{:style => "width: 62%;"}/ %col{:style => "width: 14%;"}/ @@ -71,7 +71,10 @@ %span= t(:total) %tbody %tr.item{ id: "sli_{{$index}}", ng: { repeat: "item in subscription.subscription_line_items | filter:{ _destroy: '!true' }", class: { even: 'even', odd: 'odd' } } } - %td.description {{ item.description }} + %td + .description {{ item.description }} + .not-in-open-and-upcoming-order-cycles-warning{ ng: { if: '!item.in_open_and_upcoming_order_cycles' } } + = t(".no_open_or_upcoming_order_cycle") %td.price.align-center {{ item.price_estimate | currency }} %td.quantity {{ item.quantity }} %td.total.align-center {{ (item.price_estimate * item.quantity) | currency }} diff --git a/app/views/admin/subscriptions/_subscription_line_items.html.haml b/app/views/admin/subscriptions/_subscription_line_items.html.haml index 0be91f2c93..b80b95a85b 100644 --- a/app/views/admin/subscriptions/_subscription_line_items.html.haml +++ b/app/views/admin/subscriptions/_subscription_line_items.html.haml @@ -1,4 +1,4 @@ -%table#subscription-line-items +%table#subscription-line-items.admin-subscription-form-subscription-line-items %colgroup %col{:style => "width: 49%;"}/ %col{:style => "width: 14%;"}/ @@ -15,7 +15,10 @@ %th.orders-actions.actions %tbody %tr.item{ id: "sli_{{$index}}", ng: { repeat: "item in subscription.subscription_line_items | filter:{ _destroy: '!true' }", class: { even: 'even', odd: 'odd' } } } - %td.description {{ item.description }} + %td + .description {{ item.description }} + .not-in-open-and-upcoming-order-cycles-warning{ ng: { if: '!item.in_open_and_upcoming_order_cycles' } } + = t(".not_in_open_and_upcoming_order_cycles_warning") %td.price.align-center {{ item.price_estimate | currency }} %td.quantity %input{ name: 'quantity', type: 'number', min: 0, ng: { model: 'item.quantity' } } diff --git a/app/views/spree/admin/orders/bulk_management.html.haml b/app/views/spree/admin/orders/bulk_management.html.haml index 50aa10634f..950ec2a45a 100644 --- a/app/views/spree/admin/orders/bulk_management.html.haml +++ b/app/views/spree/admin/orders/bulk_management.html.haml @@ -112,7 +112,7 @@ .margin-bottom-50{ 'ng-hide' => 'RequestMonitor.loading || filteredLineItems.length == 0' } %form{ name: 'bulk_order_form' } - %table.index#listing_orders.bulk{ :class => "sixteen columns alpha" } + %table.index#listing_orders.bulk{ :class => "sixteen columns alpha", ng: { show: "initialized" } } %thead %tr{ ng: { controller: "ColumnsCtrl" } } %th.bulk @@ -157,7 +157,7 @@ = t("admin.orders.bulk_management.ask") %input{ :type => 'checkbox', 'ng-model' => "confirmDelete" } - %tr.line_item{ 'ng-repeat' => "line_item in filteredLineItems = ( lineItems | filter:quickSearch | selectFilter:supplierFilter:distributorFilter:orderCycleFilter | variantFilter:selectedUnitsProduct:selectedUnitsVariant:sharedResource | orderBy:sorting.predicate:sorting.reverse )", 'ng-class-even' => "'even'", 'ng-class-odd' => "'odd'", :id => "li_{{line_item.id}}" } + %tr.line_item{ 'ng-repeat' => "line_item in filteredLineItems = ( lineItems | filter:quickSearch | selectFilter:supplierFilter:distributorFilter:orderCycleFilter | variantFilter:selectedUnitsProduct:selectedUnitsVariant:sharedResource | orderBy:sorting.predicate:sorting.reverse )", 'ng-class-even' => "'even'", 'ng-class-odd' => "'odd'", ng: { attr: { id: "li_{{line_item.id}}" } } } %td.bulk %input{ :type => "checkbox", :name => 'bulk', 'ng-model' => 'line_item.checked', 'ignore-dirty' => true } %td.order_no{ 'ng-show' => 'columns.order_no.visible' } {{ line_item.order.number }} @@ -181,6 +181,6 @@ %input.show-dirty{ :type => 'text', :name => 'price', :id => 'price', :ng => { value: 'line_item.price * line_item.quantity | currency:""', readonly: "true", class: '{"update-error": line_item.errors.price}' } } %span.error{ ng: { bind: 'line_item.errors.price' } } %td.actions - %a{ href: "/admin/orders/{{line_item.order.number}}/edit", :class => "edit-order icon-edit no-text", 'confirm-link-click' => 'confirmRefresh()' } + %a{ ng: { href: "/admin/orders/{{line_item.order.number}}/edit" }, :class => "edit-order icon-edit no-text", 'confirm-link-click' => 'confirmRefresh()' } %td.actions %a{ 'ng-click' => "deleteLineItem(line_item)", :class => "delete-line-item icon-trash no-text" } diff --git a/app/views/spree/admin/overview/_products.html.haml b/app/views/spree/admin/overview/_products.html.haml index 37de2f11d4..539eecd943 100644 --- a/app/views/spree/admin/overview/_products.html.haml +++ b/app/views/spree/admin/overview/_products.html.haml @@ -12,7 +12,7 @@ - if @product_count > 0 %div.seven.columns.alpha.list-item %span.six.columns.alpha - = "You have #{@product_count} active product#{@product_count > 1 ? "s" : ""}." + = t(".active_products", count: @product_count ) %span.one.column.omega %span.icon-ok-sign %a.seven.columns.alpha.button.bottom.blue{ href: "#{admin_products_path}" } @@ -21,7 +21,7 @@ - else %div.seven.columns.alpha.list-item.red %span.six.columns.alpha - = t "spree_admin_enterprises_any_active_products_text" + = t(".active_products", count: @product_count ) %span.one.column.omega %span.icon-remove-sign %a.seven.columns.alpha.button.bottom.red{ href: "#{new_admin_product_path}" } diff --git a/app/views/spree/admin/reports/enterprise_fee_summaries/_filters.html.haml b/app/views/spree/admin/reports/enterprise_fee_summaries/_filters.html.haml new file mode 100644 index 0000000000..744097e9a5 --- /dev/null +++ b/app/views/spree/admin/reports/enterprise_fee_summaries/_filters.html.haml @@ -0,0 +1,50 @@ += form_for @report_parameters, as: :report, url: spree.admin_reports_enterprise_fee_summary_path, method: :post do |f| + .row.date-range-filter + .sixteen.columns.alpha + = label_tag nil, t(".date_range") + %br + + = f.label :start_at, class: "inline" + = f.text_field :start_at, class: "datetimepicker datepicker-from" + + %span.range-divider + %i.icon-arrow-right + + = f.text_field :end_at, class: "datetimepicker datepicker-to" + = f.label :end_at, class: "inline" + + .row + .sixteen.columns.alpha + = f.label :distributor_ids + = f.collection_select(:distributor_ids, @permissions.allowed_distributors, :id, :name, {}, {class: "select2 fullwidth", multiple: true}) + + .row + .sixteen.columns.alpha + = f.label :producer_ids + = f.collection_select(:producer_ids, @permissions.allowed_producers, :id, :name, {}, {class: "select2 fullwidth", multiple: true}) + + .row + .sixteen.columns.alpha + = f.label :order_cycle_ids + = f.collection_select(:order_cycle_ids, @permissions.allowed_order_cycles, :id, :name, {}, {class: "select2 fullwidth", multiple: true}) + + .row + .eight.columns.alpha + = f.label :enterprise_fee_ids + = f.collection_select(:enterprise_fee_ids, @permissions.allowed_enterprise_fees, :id, :name, {}, {class: "select2 fullwidth", multiple: true}) + .eight.columns.omega + = f.label :shipping_method_ids + = f.collection_select(:shipping_method_ids, @permissions.allowed_shipping_methods, :id, :name, {}, {class: "select2 fullwidth", multiple: true}) + + .row + .eight.columns.alpha   + .eight.columns.omega + = f.label :payment_method_ids + = f.collection_select(:payment_method_ids, @permissions.allowed_payment_methods, :id, :name, {}, {class: "select2 fullwidth", multiple: true}) + + .row + .sixteen.columns.alpha + = check_box_tag :report_format, "csv", false, id: "report_format_csv" + = label_tag :report_format_csv, t(".report_format_csv") + + = button t(".generate_report") diff --git a/app/views/spree/admin/reports/enterprise_fee_summaries/_report.html.haml b/app/views/spree/admin/reports/enterprise_fee_summaries/_report.html.haml new file mode 100644 index 0000000000..4427c511ba --- /dev/null +++ b/app/views/spree/admin/reports/enterprise_fee_summaries/_report.html.haml @@ -0,0 +1,19 @@ +- if @report.present? + %table#enterprise_fee_summary_report.report__table + %thead + %tr + - @renderer.header.each do |heading| + %th= heading + + %tbody + - @renderer.data_rows.each do |row| + %tr + - row.each do |cell_value| + %td= cell_value + + - if @renderer.data_rows.empty? + %tr + %td{colspan: @renderer.header.length}= t(:none) +- else + %p.report__message + = t(".select_and_search") diff --git a/app/views/spree/admin/reports/enterprise_fee_summaries/create.html.haml b/app/views/spree/admin/reports/enterprise_fee_summaries/create.html.haml new file mode 100644 index 0000000000..6d8f0c79ab --- /dev/null +++ b/app/views/spree/admin/reports/enterprise_fee_summaries/create.html.haml @@ -0,0 +1,2 @@ += render "filters" += render "report" diff --git a/app/views/spree/admin/reports/enterprise_fee_summaries/new.html.haml b/app/views/spree/admin/reports/enterprise_fee_summaries/new.html.haml new file mode 100644 index 0000000000..790853ca1f --- /dev/null +++ b/app/views/spree/admin/reports/enterprise_fee_summaries/new.html.haml @@ -0,0 +1 @@ += render "filters" diff --git a/app/views/spree/admin/reports/index.html.erb b/app/views/spree/admin/reports/index.html.erb new file mode 100644 index 0000000000..cb514a5291 --- /dev/null +++ b/app/views/spree/admin/reports/index.html.erb @@ -0,0 +1,21 @@ +<% content_for :page_title do %> + <%= t(:listing_reports) %> +<% end %> + + + + + + + + + + <% @reports.each do |key, value| %> + + + + + <% end %> + +
<%= t(:name) %><%= t(:description) %>
<%= link_to value[:name], value[:url] %><%= value[:description] %>
+ diff --git a/config/locales/ca.yml b/config/locales/ca.yml new file mode 100644 index 0000000000..4c2341161a --- /dev/null +++ b/config/locales/ca.yml @@ -0,0 +1,2649 @@ +ca: + language_name: "Català" + activerecord: + attributes: + spree/order: + payment_state: Estat del Pagament + shipment_state: Estat de la Tramesa + completed_at: Completat a + number: Número + state: Estat + email: Correu electrònic de la consumidora + spree/payment: + amount: Import + order_cycle: + orders_close_at: Data de tancament + errors: + models: + spree/user: + attributes: + email: + taken: "Ja hi ha un compte per a aquest correu electrònic. Si us plau, inicia sessió o restableix la contrasenya." + spree/order: + no_card: No hi ha targetes de crèdit autoritzades disponibles per carregar + order_cycle: + attributes: + orders_close_at: + after_orders_open_at: s'ha de fer amb el termini obert + variant_override: + count_on_hand: + using_producer_stock_settings_but_count_on_hand_set: "ha d'estar en blanc perquè s'utilitza la configuració de l'inventari de la productora" + on_demand_but_count_on_hand_set: "ha d'estar en blanc si és sota demanda" + limited_stock_but_no_count_on_hand: "cal especificar-se perquè força existències limitades" + activemodel: + errors: + models: + subscription_validator: + attributes: + subscription_line_items: + at_least_one_product: "^Afegiu com a mínim un producte" + not_available: "^ %{name} no està disponible a l'horari seleccionat" + ends_at: + after_begins_at: "ha de ser després de" + customer: + does_not_belong_to_shop: "no pertany a %{shop}" + schedule: + not_coordinated_by_shop: "no està coordinat per %{shop}" + payment_method: + not_available_to_shop: "no està disponible per %{shop}" + invalid_type: "el mètode ha de ser Efectiu o Stripe" + charges_not_allowed: "^ Els càrrecs de la targeta de crèdit no estan permesos per aquest consumidora" + no_default_card: "^ No hi ha cap targeta predeterminada disponible per a aquesta consumidora" + shipping_method: + not_available_to_shop: "no està disponible per %{shop}" + devise: + confirmations: + send_instructions: "Rebreu un correu electrònic amb instruccions sobre com confirmar el vostre compte en pocs minuts." + failed_to_send: "S'ha produït un error en enviar el correu electrònic de confirmació." + resend_confirmation_email: "Reenvia el correu electrònic de confirmació." + confirmed: "Gràcies per confirmar el vostre correu electrònic. Ara podeu iniciar sessió." + not_confirmed: "No s'ha pogut confirmar la vostra adreça de correu electrònic. Potser ja heu completat aquest pas?" + user_registrations: + spree_user: + signed_up_but_unconfirmed: "S'ha enviat un missatge amb un enllaç de confirmació a la teva adreça de correu electrònic. Obre l'enllaç per activar el teu compte." + unknown_error: "S'ha produït un error en crear el teu compte. Comprova la teva adreça de correu electrònic i torna-ho a provar." + failure: + invalid: | + Correu electrònic o contrasenya no vàlids. + Va ser un convitat la última vegada? Potser vostè necessita crear un compte o restablir la contrasenya. + unconfirmed: "Heu de confirmar el vostre compte abans de continuar." + already_registered: "Aquesta adreça electrònica ja està registrada. Inicieu sessió per continuar o torneu endarrere per utilitzar una altra adreça de correu electrònic." + user_passwords: + spree_user: + updated_not_active: "La vostra contrasenya s'ha restablert, però el vostre correu electrònic encara no s'ha confirmat." + models: + order_cycle: + cloned_order_cycle_name: "CÒPIA DE %{order_cycle}" + enterprise_mailer: + confirmation_instructions: + subject: "Sisplau, confirma l'adreça electrònica d'%{enterprise}" + welcome: + subject: "%{enterprise} és ara %{sitename}" + invite_manager: + subject: "%{enterprise} t'ha convidat a ser administrador" + order_mailer: + cancel_email: + dear_customer: "Benvolguda consumidora:" + instructions: "S'ha cancel·lat la vostra comanda. Conserveu aquesta informació com a comprovant de la cancel·lació." + order_summary_canceled: "Resum de comanda [CANCEL·LAT]" + subject: "Cancel·lació de la comanda" + subtotal: "Total parcial: %{subtotal}" + total: "Total comanda: %{total}" + producer_mailer: + order_cycle: + subject: "Informe del cicle de comanda per %{producer}" + shipment_mailer: + shipped_email: + dear_customer: "Benvolguda consumidora:" + instructions: "La vostra comanda s'ha enviat" + shipment_summary: "Detalls de l'enviament" + subject: "Notificació d'enviament" + thanks: "Gràcies per la teva compra." + track_information: "Informació del seguiment: %{tracking}" + track_link: "Enllaç del seguiment: %{url}" + subscription_mailer: + placement_summary_email: + subject: Un resum de les comandes per subscripció recentment establertes + greeting: "Hola %{name}," + intro: "A continuació es mostra un resum de les comandes per subscripció que s'acaben de col·locar per %{shop}." + confirmation_summary_email: + subject: Un resum de les comandes per subscripció confirmades recentment + greeting: "Hola %{name}," + intro: "A continuació es mostra un resum de les comandes per subscripció que acaben de finalitzar per %{shop}." + summary_overview: + total: S'ha marcat un total de subscripcions %{count} per al processament automàtic. + success_zero: D'aquestes, cap d'elles s'ha processat correctament. + success_some: D'aquests, %{count} s'han processat correctament. + success_all: Tots s'han processat correctament. + issues: A continuació es detallen els problemes trobats. + summary_detail: + no_message_provided: No s'ha proporcionat cap missatge d'error + changes: + title: Stock insuficient (%{count} comandes) + explainer: Aquestes comandes es van processar, però no hi havia suficients estocs per a alguns articles sol·licitats + empty: + title: Sense Stock (%{count} comandes) + explainer: Aquestes comandes no s'han pogut processar perquè no hi ha disponible cap estoc per a cap dels elements sol·licitats + complete: + title: Ja està processat (%{count} comandes) + explainer: Aquestes comandes ja estaven marcades com a completes i, per tant, no es modificarán + processing: + title: S'ha trobat un error (%{count} comandes) + explainer: El processament automàtic d'aquestes comandes ha fallat a causa d'un error. L'error es mostra sempre que sigui possible. + failed_payment: + title: Pagament erroni (%{count} comandes) + explainer: La tramitació automàtica del pagament d'aquestes comandes va fallar a causa d'un error. L'error es mostra sempre que sigui possible. + other: + title: Altres errors (%{count} comandes) + explainer: El processament automàtic d'aquestes comandes va fallar per un motiu desconegut. Això no hauria de passar, si us plau, contacteu amb nosaltres si esteu veient això. + home: "OFN" + title: Open Food Network + welcome_to: 'Benvingut a' + site_meta_description: "Comencem des de baix. Amb agricultors i productors disposats a explicar les seves històries amb orgull i sinceritat. Amb distribuïdors connectant persones i productes de manera justa i honesta. Amb compradors que creuen que millors decisions de compra setmanal poden ..." + search_by_name: Cercar per nom o barri ... + producers_join: Els productors australians son ara benvinguts a unir-se a la Open Food Network. + charges_sales_tax: Càrrecs GST? + print_invoice: "Imprimir Factura" + print_ticket: "Imprimir Ticket" + select_ticket_printer: "Seleccionar la impressora de tickets" + send_invoice: "Enviar Factura" + resend_confirmation: "Reenviar Confirmació" + view_order: "Veure Comanda" + edit_order: "Editar Comanda" + ship_order: "Enviar Comanda" + cancel_order: "Cancel·lar Comanda" + confirm_send_invoice: "Una factura per aquesta comanda s'enviarà al client. Està segur de continuar?" + confirm_resend_order_confirmation: "Segur que vol reenviar el correu de confirmació de la comanda?" + must_have_valid_business_number: "%{enterprise_name} ha de tenir un ABN vàlid abans de les factures es poden enviar." + invoice: "Factura" + percentage_of_sales: "%{percentage} de vendes" + capped_at_cap: "llimitat a %{cap}" + per_month: "al mes" + free: "gratuït" + free_trial: "prova gratuïta" + plus_tax: "més GST" + min_bill_turnover_desc: "una vegada que el volum de negocis supere %{mbt_amount}" + more: "Més" + say_no: "No" + say_yes: "Si" + then: llavors + ongoing: En marxa + bill_address: Adreça de facturació + ship_address: Adreça d'enviament + sort_order_cycles_on_shopfront_by: "Ordenar Cicles de Comanda de la Botiga per" + required_fields: Els camps obligatoris estan marcats amb un asterisc + select_continue: Seleccionar i Continuar + remove: Eliminar + or: o + collapse_all: Col·lapsar tot + expand_all: Expandir tot + loading: Carregant... + show_more: Mostrar més + show_all: Mostrar tots + show_all_with_more: "Mostrar tot (%{num} més)" + cancel: Cancel·lar + edit: Editar + clone: Clonar + distributors: Distribuïdors + distribution: Distribució + bulk_order_management: Gestió de comandes en bloc + enterprises: Organitzacions + enterprise_groups: Grups + reports: Informes + variant_overrides: Inventari + spree_products: Productes Spree + all: Tots + current: Actual + available: Disponible + dashboard: Panell + undefined: indefinit + unused: no utilitzat + admin_and_handling: Administració i manipulació + profile: Perfil + supplier_only: Només proveïdor + has_shopfront: Té botiga + weight: Pes + volume: Volum + items: Articles + summary: Resum + detailed: Detallada + updated: Actualitzat + 'yes': "Sí" + 'no': "No" + y: 'S' + n: 'N' + powered_by: Impulsat per + blocked_cookies_alert: "El vostre navegador pot estar bloquejant les galetes necessàries per utilitzar aquesta botiga. Feu clic a continuació per permetre les galetes i tornar a carregar la pàgina." + allow_cookies: "Permet les galetes" + notes: Notes + error: Error + processing_payment: S'està processant el pagament ... + show_only_unfulfilled_orders: Mostra només comandes no realitzades + filter_results: Resultats del filtre + quantity: Quantitat + pick_up: Recollida + copy: Còpia + password_confirmation: Confirmació de la contrassenya + reset_password_token: Reinicia el token de contrasenya + expired: ha caducat, si us plau, sol·liciteu-ne un de nou + back_to_payments_list: "Torna a la llista de pagaments" + actions: + create_and_add_another: "Crea i afegeix-ne una altra" + admin: + begins_at: Comença a + begins_on: Comença + customer: Consumidora + date: Data + email: E-mail + ends_at: Acaba a + ends_on: Finalitza + name: Nom + on_hand: Disponibles + on_demand: Sota demanda + on_demand?: Sota demanda? + order_cycle: Cicle de Comanda + payment: Pagament + payment_method: Mètode de pagament + phone: Telèfon + price: Preu + producer: Productor + image: Imatge + product: Producte + quantity: Quantitat + schedule: Horari + shipping: Enviament + shipping_method: Mètode d'enviament + shop: Botiga + sku: SKU + status_state: Estat + tags: Etiquetes + variant: Variant + weight: Pes + volume: Volum + items: Articles + select_all: Seleccionar tot + obsolete_master: Mestre obsolet + quick_search: Cerca Ràpida + clear_all: Esborrar tot + start_date: "Data d'inici" + end_date: "Data de finalització" + form_invalid: "El formulari conté camps que falten o no son vàlids" + clear_filters: Netejar Filtres + clear: Netejar + save: Desa + cancel: Cancel·lar + back: Enrere + show_more: Mostra més + show_n_more: Mostra %{num} més + choose: "Tria ..." + please_select: Seleccioneu ... + columns: Columnes + actions: Accions + viewing: "Veient: %{current_view_name}" + description: Descripció + whats_this: Què és això? + tag_has_rules: "Regles existents per a aquesta etiqueta: %{num}" + has_one_rule: "té una regla" + has_n_rules: "té%{num} regles" + unsaved_confirm_leave: "Hi ha canvis no desats canviats en aquesta pàgina. Continua sense desar?" + unsaved_changes: "Teniu canvis sense desar" + accounts_and_billing_settings: + method_settings: + default_accounts_payment_method: "Mètode de pagament de comptes predeterminats" + default_accounts_shipping_method: "Mètode d'enviament de comptes predeterminades" + edit: + accounts_and_billing: "Comptes i facturació" + accounts_administration_distributor: "'administració de comptes de distribuïdora" + admin_settings: "Configuració" + update_invoice: "Actualitza les factures" + auto_update_invoices: "Actualitzeu les factures cada dia a la 1:00 a.m." + finalise_invoice: "Finalitzar les factures" + auto_finalise_invoices: "Finalitzar automàticament les factures mensualment el dia 2 a la 1:30 a.m." + manually_run_task: "Executa la tasca manualment" + update_user_invoice_explained: "Utilitzeu aquest botó per actualitzar immediatament les factures del mes actual per a cada usuari d'organització del sistema. Aquesta tasca es pot configurar per executar-se automàticament cada nit." + finalise_user_invoices: "Finalitza les factures d'usuari" + finalise_user_invoice_explained: "Utilitzeu aquest botó per finalitzar totes les factures del sistema per al mes natural anterior. Aquesta tasca es pot configurar per executar-se automàticament un cop al mes." + update_user_invoices: "Actualitzeu factures d'usuari" + errors: + accounts_distributor: s'ha d'establir si voleu crear factures per a usuàries de la organització. + default_payment_method: s'ha d'establir si voleu crear factures per a usuàries de la organització. + default_shipping_method: s'ha d'establir si voleu crear factures per a usuàries de la organització. + shopfront_settings: + embedded_shopfront_settings: "Configuració de botiga incrustada" + enable_embedded_shopfronts: "Habilita les botigues incrustades" + embedded_shopfronts_whitelist: "Llista blanca de dominis externs" + number_localization: + number_localization_settings: "Configuració de localització numèrica" + enable_localized_number: "Utilitzeu l'estàndard internacional per separar milers/decimals" + business_model_configuration: + edit: + business_model_configuration: "Model de sostenibilitat" + business_model_configuration_tip: "Configureu la tarifa què es cobrarà a les botigues cada mes per utilitzar la Xarxa Open Food." + bill_calculation_settings: "Configuració del càlcul de ticket" + bill_calculation_settings_tip: "Ajusteu la quantitat que es facturarà a les organitzacions cada mes per utilitzar l'OFN." + shop_trial_length: "Durada de la prova de la botiga (dies)" + shop_trial_length_tip: "El temps (en dies) que les organitzacions que es configuren com a botigues poden funcionar amb període de prova." + fixed_monthly_charge: "Càrrec mensual fixat" + fixed_monthly_charge_tip: "Un càrrec mensual fix per a totes les organitzacions que es configuren com a botiga i que hagin superat la facturació mínima (si s'estableix)." + percentage_of_turnover: "Percentatge de la facturació" + percentage_of_turnover_tip: "Quan sigui superior a zero, aquesta taxa (0,0 - 1,0) s'aplicarà a la facturació total de cada botiga i s'afegeix a qualsevol càrrec fixat (a l'esquerra) per calcular la factura mensual." + monthly_cap_excl_tax: "Límit mensual (exclòs GST)" + monthly_cap_excl_tax_tip: "Quan sigui superior a zero, aquest valor s'utilitzarà com a límit en la quantitat que es cobrarà a les botigues cada mes." + tax_rate: "Impost" + tax_rate_tip: "Impost que s'aplica a la factura mensual que es cobra a les organitzacions per utilitzar el sistema." + minimum_monthly_billable_turnover: "Mínim volum de vendes facturable al mes" + minimum_monthly_billable_turnover_tip: "La facturació mensual mínima abans que a una botiga se li cobri per l'ús d'OFN. Les organitzacions que facturin menys d'aquest import en un mes no es cobraran, ja sigui per percentatge o per tipus fix." + example_bill_calculator: "Exemple de calculadora de rebut" + example_bill_calculator_legend: "Després de l'exemple de volum de ventes per visualitzar l'efecte de la configuració a l'esquerra." + example_monthly_turnover: "Exemple de facturació mensual" + example_monthly_turnover_tip: "Un exemple de volum de ventes mensual per a una organització que es farà servir per calcular un exemple de factura mensual més a baix." + cap_reached?: "S'ha arribat a la capacitat?" + cap_reached?_tip: "Si s'ha arribat al límit (especificat a l'esquerra), tenint en compte la configuració i el volum de ventes proporcionat." + included_tax: "Impostos incloses" + included_tax_tip: "Els impostos totals inclosos a la factura mensual d'exemple, tenint en compte la configuració i la facturació proporcionada." + total_monthly_bill_incl_tax: "Factura mensual total (impostos inclosos)" + total_monthly_bill_incl_tax_tip: "L'exemple total de la factura mensual amb impostos inclosos, tenint en compte la configuració i la facturació proporcionada." + cache_settings: + show: + title: Emmagatzematge ocult + distributor: Distribuïdora + order_cycle: Cicle de Comanda + status: Estat + diff: Diferència + error: Error + invoice_settings: + edit: + title: Configuració de la factura + invoice_style2?: Utilitzeu el model de factura alternatiu que inclou el desglossament dels càrregs totals per tarifa i la informació sobre la taxa impositiva per article (encara no està disponible per als països sense recàrregs en concpte d'impostos) + enable_receipt_printing?: Mostra les opcions per imprimir rebuts amb impressores tèrmiques en el menú desplegable de la comanda? + stripe_connect_settings: + edit: + title: "Stripe Connect" + settings: "Configuració" + stripe_connect_enabled: Permetre que les botigues acceptin pagaments mitjançant Stripe Connect? + no_api_key_msg: No hi ha cap compte de Stripe per a aquesta organització. + configuration_explanation_html: Per obtenir instruccions detallades sobre la configuració de la integració de Stripe Connect, consulteu aquesta guia . + status: Estat + ok: Correcte + instance_secret_key: Exemple de clau secreta + account_id: Identificador del compte + business_name: Nom de l'empresa + charges_enabled: Càrrecs habilitats + charges_enabled_warning: "Advertència: els càrrecs no estan habilitats per al vostre compte" + auth_fail_error: La clau API que heu proporcionat no és vàlida + empty_api_key_error_html: No s'ha proporcionat cap clau d'API de Stripe. Per configurar la clau API, seguiu aquestes instruccions < / a> + matomo_settings: + edit: + title: "Configuració de Matomo" + matomo_url: "URL de Matomo" + matomo_site_id: "Identificador de Matomo" + info_html: "Matomo és un analitzador de webs i mòbils. Podeu allotjar Matomo de manera local o utilitzar un servei al núvol. Vegeu matomo.org per obtenir més informació." + config_instructions_html: "Aquí podeu configurar la integració Matomo OFN. L'URL de Matomo que us apareix a continuació ha d'indicar la instància de Matomo en la qual s'enviarà la informació de seguiment de l'usuari; si es deixa buit, el seguiment de l'usuari de Matomo estarà desactivat. El camp d'identificació del lloc no és obligatori, però és útil si fa un seguiment de més d'un lloc web en una sola instància de Matomo; es pot trobar a la consola d'instància de Matomo." + customers: + index: + add_customer: "Afegeix consumidora" + new_customer: "Nova consumidora" + customer_placeholder: "consumidora@example.org" + valid_email_error: siusplau, introduïu una adreça de correu electrònic vàlida + add_a_new_customer_for: Afegiu una consumidora nova per %{shop_name} + code: Codi + duplicate_code: "Aquest codi ja s'ha utilitzat." + bill_address: "Adreça de facturació" + ship_address: "Adreça d'enviament" + update_address_success: 'L''adreça s''ha actualitzat correctament.' + update_address_error: 'Ho sentim! Introduïu tots els camps obligatoris.' + edit_bill_address: 'Edita l''adreça de facturació' + edit_ship_address: 'Edita l''adreça d''enviament' + required_fileds: 'Els camps obligatoris es denoten amb un asterisc' + select_country: 'Selecciona país' + select_state: 'Selecciona província' + edit: 'Editar' + update_address: 'Actualitza l''adreça' + confirm_delete: 'Estàs segur que vols suprimir?' + search_by_email: "Cerca per correu electrònic/codi ..." + guest_label: 'Fer comanda com a convidat' + destroy: + has_associated_orders: 'S''ha produït un error en suprimir: la consumidora té comandes associades amb la seva botiga' + contents: + edit: + title: Contingut + header: Capçalera + home_page: Pàgina d'inici + producer_signup_page: Pàgina d'inscripció de productora + hub_signup_page: Pàgina d'inscripció de grup + group_signup_page: Pàgina d'inscripció de grup + main_links: Enllaços del menú principal + footer_and_external_links: Peu de pàgina i enllaços externs + your_content: El vostre contingut + user_guide: Guia de l'usuari + enterprise_fees: + index: + title: Tarifes de l'organització + enterprise: Organització + fee_type: Tipus de tarifa + name: Nom + tax_category: Categoria d'impostos + calculator: Calculadora + calculator_values: Valors de la calculadora + enterprise_groups: + index: + new_button: Grup d'organització nou + enterprise_roles: + form: + manages: gestiona + enterprise_role: + manages: gestiona + products: + unit_name_placeholder: 'exemple: grapats' + index: + unit: Unitat + display_as: Mostra com + category: Categoria + tax_category: Categoria d'impostos + inherits_properties?: Hereda propietats? + available_on: Disponible el + import_date: S'ha importat + upload_an_image: Penja una imatge + product_search_keywords: Paraules clau de cerca de producte + product_search_tip: Escriviu paraules per ajudar-vos a cercar els vostres productes a les botigues. Utilitzeu espai per separar cada paraula clau. + SEO_keywords: Paraules clau de SEO + seo_tip: Escriviu paraules per ajudar-vos a cercar els vostres productes a la web. Utilitzeu espai per separar cada paraula clau. + Search: Cerca + properties: + property_name: Nom de la propietat + inherited_property: Propietat heretada + variants: + to_order_tip: "Els articles preparats per encàrrec no tenen un nivell fixat d'existències, com ara pa fet sota comanda." + product_distributions: "Distribucions de productes" + group_buy_options: "Opcions de compra en grup" + back_to_products_list: "Torna a la llista de productes" + product_import: + title: Importació de productes + file_not_found: No s'ha trobat el fitxer o no s'ha pogut obrir + no_data: No s'ha trobat cap dada al full de càlcul + confirm_reset: "Això farà que el nivell d'existències sigui zero en tots els productes d'aquesta\n organització que no estan presents en el fitxer carregat" + model: + no_file: "error: no s'ha carregat cap fitxer" + could_not_process: "no s'ha pogut processar el fitxer: tipus de fitxer no vàlid" + incorrect_value: valor incorrecte + conditional_blank: no es pot deixar en blanc si el tipus d'unitat està en blanc + no_product: no s'ha trobat cap producte a la base de dades + not_found: no es troba a la base de dades + not_updatable: No es poden actualitzar els productes existents mitjançant la importació de productes + blank: no es pot deixar en blanc + products_no_permission: no tens el permís per gestionar els productes d'aquesta organització + inventory_no_permission: no tens el permís per crear inventari per a aquesta productora + none_saved: no s'han desat cap producte amb èxit + line_number: "Línia %{number}:" + index: + select_file: Selecciona un full de càlcul per pujar-lo + spreadsheet: Full de càlcul + choose_import_type: Selecciona el tipus d'importació + import_into: Tipus d'importació + product_list: Llista de productes + inventories: Inventari + import: Importa + upload: Carrega + csv_templates: Plantilles de CSV + product_list_template: Descarrega la plantilla de la llista de productes + inventory_template: Descarrega la plantilla d'inventari + category_values: Valors de la categoria disponibles + product_categories: Tipus de productes + tax_categories: Tipus d'impostos + shipping_categories: Tipus d'enviament + import: + review: Revisa + import: Importa + save: Desa + results: Resultats + save_imported: Desa els productes importats + no_valid_entries: No s'han trobat entrades vàlides + none_to_save: No hi ha entrades que es puguin desar + some_invalid_entries: El fitxer importat conté entrades no vàlides + fix_before_import: Si us plau, corregeix aquests errors i torna a importar el fitxer + save_valid?: Vols desar per ara les entrades vàlides i descartar les altres? + no_errors: No s'ha detectat cap error! + save_all_imported?: Vols desar tots els productes importats? + options_and_defaults: Importa opcions i valors predeterminats + no_permission: no tens permís per gestionar aquesta organització + not_found: No s'ha pogut trobar l'organització a la base de dades + no_name: Sense nom + blank_enterprise: alguns productes no tenen una organització definida + reset_absent?: Restabliu els productes absents + reset_absent_tip: Establiu valors a zero per a tots els productes existents que no figurin al fitxer + overwrite_all: Sobreescriu-ho tot + overwrite_empty: Sobreescriu si està buit + default_stock: Estableix el nivell d'existències + default_tax_cat: Estableix la categoria d'impostos + default_shipping_cat: Estableix la categoria d'enviament + default_available_date: Estableix data de disponibilitat + validation_overview: Importa la descripció general de la validació + entries_found: S'han trobat entrades al fitxer importat + entries_with_errors: Els articles contenen errors i no s'importaran + products_to_create: Els productes es crearan + products_to_update: Els productes s'actualitzaran + inventory_to_create: Els articles de l'inventari es crearan + inventory_to_update: Els articles de l'inventari s'actualitzaran + products_to_reset: Els productes existents reajustaran el nivell d'existències a zero + inventory_to_reset: Els articles de l'inventari existents tindran el restabliment d'existències a zero + line: Línia + item_line: Línia d'article + import_review: + not_updatable_tip: "Els següents camps no es poden actualitzar mitjançant importació en bloc per a productes existents:" + fields_ignored: Aquests camps s'ignoraran quan es guardin els productes importats. + entries_table: + not_updatable: Aquest camp no es pot actualitzar mitjançant la importació en bloc per a productes existents + save_results: + final_results: Importa els resultats finals + products_created: Productes creats + products_updated: Productes actualitzats + inventory_created: S'han creat articles d'inventari + inventory_updated: 'S''han actualitzat articles d''inventari ' + products_reset: S'ha reestablert el nivell d'existències dels productes a zero + inventory_reset: S'ha reestablert el nivell d'estoc dels articles de l'inventari a zero + all_saved: "Tots els articles s'han desat correctament" + some_saved: "els articles s'han desat correctament" + save_errors: Desa els errors + import_again: Penja un altre fitxer + view_products: Anar a la pàgina de Productes + view_inventory: Anar a la pàgina d'Inventari + variant_overrides: + loading_flash: + loading_inventory: CARREGANT INVENTARI + index: + title: Inventari + description: Utilitzeu aquesta pàgina per administrar els inventaris de la vostres organitzacions. Tots els detalls del producte aquí establerts substituiran els establerts a la pàgina "Productes" + enable_reset?: Habilitar la restauració de valors de stock? + inherit?: Heredar? + add: Afegeix + hide: Amaga + import_date: S'ha importat + select_a_shop: Seleccioneu una botiga + review_now: Reviseu ara + new_products_alert_message: Hi ha productes nous %{new_product_count} disponibles per afegir al vostre inventari. + currently_empty: El vostre inventari està buit + no_matching_products: No es troben productes coincidents al vostre inventari + no_hidden_products: No s'ha amagat cap producte d'aquest inventari + no_matching_hidden_products: Els productes ocults no coincideixen amb els criteris de cerca + no_new_products: No hi ha productes nous disponibles per afegir-los a aquest inventari + no_matching_new_products: Cap producte nou coincideix amb els criteris de cerca + inventory_powertip: Aquest és el vostre inventari de productes. Per afegir productes al vostre inventari, seleccioneu "Nous productes" al menú desplegable Visualització. + hidden_powertip: Aquests productes s'han ocultat al vostre inventari i no estaran disponibles per afegir-los a la vostra botiga. Podeu fer clic a "Afegeix" per afegir un producte a l'inventari. + new_powertip: Aquests productes estan disponibles per afegir al vostre inventari. Feu clic a "Afegeix" per afegir un producte al vostre inventari o "Oculta" per ocultar-lo de la vista. Sempre podreu canviar aquestes opcions després. + controls: + back_to_my_inventory: Torna al meu inventari + orders: + invoice_email_sent: 'S''ha enviat el correu electrònic de la factura' + order_email_resent: 'S''ha reenviat el correu electrònic de la comanda' + bulk_management: + tip: "Utilitzeu aquesta pàgina per alterar les quantitats de productes en diverses comandes. Els productes també es poden eliminar de les comandes completament, si es requereix." + shared: "Recurs compartit?" + order_no: "Nº de comanda." + order_date: "Completada a" + max: "Màx" + product_unit: "Producte: Unitat" + weight_volume: "Pes/Volum" + ask: "Preguntar?" + page_title: "Gestió de les comandes en bloc" + actions_delete: "Suprimeix seleccionats" + loading: "Carregant comandes" + no_results: "No s'han trobat comandes." + group_buy_unit_size: "Mida d'unitat de grup de compra" + total_qtt_ordered: "Quantitat total demanada" + max_qtt_ordered: "Quantitat màxima demanada" + current_fulfilled_units: "Unitats actuals fetes" + max_fulfilled_units: "Unitats màximes fetes" + order_error: "S'han de resoldre alguns errors abans de poder actualitzar les comandes.\nQualsevol camp amb vora vermella conté errors." + variants_without_unit_value: "Atenció: algunes variants no tenen cap unitat assignada." + select_variant: "Selecciona un paràmetre" + enterprise: + select_outgoing_oc_products_from: Selecciona els productes sortints del Cicle de Comandes + enterprises: + index: + title: Organitzacions + new_enterprise: Nova organització + producer?: "Productora?" + package: Perfil + status: Estat + manage: Gestiona + form: + about_us: + desc_short: Descripció breu + desc_short_placeholder: Explica'ns sobre la teva organització en una o dues frases + desc_long: Sobre nosaltres + desc_long_placeholder: Explica coses sobre tu als clients. Aquesta informació apareixerà al teu perfil públic. + business_details: + abn: NIF + abn_placeholder: p. ex. F987654321 + acn: NIF + acn_placeholder: p. ex. 123 456 789 + display_invoice_logo: Mostreu el logotip a les factures + invoice_text: Afegeix text personalitzat al final de les factures + contact: + name: Nom + name_placeholder: 'p. ex: Josep Ribes' + email_address: Adreça electrònica pública + email_address_placeholder: 'p. ex: contacte@hortajosepribes.com' + email_address_tip: "Aquesta adreça de correu electrònic es mostrarà al vostre perfil públic" + phone: Telèfon + phone_placeholder: p. ex. 98 765 43 21 + website: Lloc web + website_placeholder: 'p. ex.: www.hortajosepribes.com' + enterprise_fees: + name: Nom + fee_type: Tipus de tarifa + manage_fees: Gestioneu les tarifes de l'organització + no_fees_yet: Encara no tens cap tipus de comissió de l'organització + create_button: Crea'n una ara + images: + logo: Logotip + promo_image_placeholder: 'Aquesta imatge es mostra a "Sobre Nosaltres"' + promo_image_note1: 'ATENCIÓ:' + promo_image_note2: Qualsevol imatge promocional que es carregui aquí es tallarà a 1200 x 260. + promo_image_note3: 'La imatge promocional es mostra a la part superior de la pàgina de perfil i finestres emergents d''una organització ' + inventory_settings: + text1: 'Pots optar per gestionar els nivells d''existències i els preus a través del teu ' + inventory: inventari + text2: > + Si utilitzes l'eina d'inventari, pots seleccionar si els nous productes + afegits pels teus proveïdors han de ser afegits al teu inventari primerament, + abans de poder emmagatzemar-los a la botiga. Si no estàs utilitzant + l'inventari per gestionar els teus productes, has de seleccionar l'opció + "recomanada" següent: + preferred_product_selection_from_inventory_only_yes: Es poden introduir nous productes directament a la meva botiga (recomanat) + preferred_product_selection_from_inventory_only_no: He d'afegir els nous productes a l'inventari abans de poder-los mostrar a la meva botiga. + payment_methods: + name: Nom + applies: Aplicar? + manage: Gestiona els mètodes de pagament + not_method_yet: Encara no tens cap mètode de pagament. + create_button: Crea un nou mètode de pagament + create_one_button: Crea'n un ara + primary_details: + name: Nom + name_placeholder: p. ex. Horta Josep Ribes + groups: Grups + groups_tip: Seleccioneu grups o xarxes de la quals sou membres. Això ajudarà les consumidores a trobar la vostra organització o empresa. + groups_placeholder: Comenceu a escriure per cercar xarxes disponibles... + primary_producer: Productora principal? + primary_producer_tip: Selecciona "Productora" si ets productora principal d'aliments. + producer: Productora + any: Cap + none: No productora + own: Propi + sells: Ven + sells_tip: "Cap: l'organització no ven als clients directament.
Propietari: l'organització ven productes propis als clients.
Qualsevol: l'organització pot vendre productes propis o d'altres empreses.
" + visible_in_search: Visible a la cerca? + visible_in_search_tip: Determina si aquesta organització serà visible per a les consumidores en cercar el lloc. + visible: Visible + not_visible: No visible + permalink: Permalink (sense espais) + permalink_tip: "Aquest enllaç permanent s'utilitza per crear l'url a la vostra botiga: %{link}your-shop-name / shop" + link_to_front: Enllaç a la botiga + link_to_front_tip: Un enllaç directe a la vostra botiga a l'Open Food Netwok. + shipping_methods: + name: Nom + applies: 'Aplicar? ' + manage: Gestiona els mètodes d'enviament + create_button: Crea un nou mètode d'enviament + create_one_button: Crea'n un ara + no_method_yet: Encara no teniu cap mètode d'enviament. + shop_preferences: + shopfront_requires_login: "Botiga visible públicament?" + shopfront_requires_login_tip: "Trieu si les consumidores han d'iniciar sessió per veure la botiga o si és visible per a tothom." + shopfront_requires_login_false: "Públic" + shopfront_requires_login_true: "Només visible per a usuaris registrats" + recommend_require_login: "Recomanem que es requeireixi inici de sessió quan l'opció de poder modificar les comandes estigui habilitada." + allow_guest_orders: "Comandes de convidats" + allow_guest_orders_tip: "Permet fer comanda com a convidat o requereix un usuari registrat." + allow_guest_orders_false: "Demana un inici de sessió per realitzar la comanda" + allow_guest_orders_true: "Permet comandes de convidats" + allow_order_changes: "Canviar les comandes" + allow_order_changes_tip: "Permetre que les consumidores canviïn o cancel·lin la seva comanda mentre el cicle de comanda estigui obert." + allow_order_changes_false: "Les comandes realitzades no es poden canviar / cancel·lar." + allow_order_changes_true: "Les consumidores poden canviar o cancel·lar les comandes mentre el cicle de comanda està obert." + enable_subscriptions: "Subscripcions" + enable_subscriptions_tip: "Habilitar la funcionalitat de subscripcions?" + enable_subscriptions_false: "Deshabilitat" + enable_subscriptions_true: "Habilitat" + shopfront_message: Missatge de la botiga + shopfront_message_placeholder: > + Una explicació opcional per a les consumidores que detalla com funciona + la vostra botiga, que es mostrarà a sobre de la llista de productes. + shopfront_closed_message: Missatge de tancament de la botiga + shopfront_closed_message_placeholder: > + Un missatge que proporciona una explicació més detallada sobre per què + la vostra botiga està tancada i / o quan poden esperar les consumidores + que es tornarà a obrir. Això només es mostrarà a la vostra botiga quan + no hi hagi cicles de comanda actius (és a dir, quan la botiga estigui + tancada). + shopfront_category_ordering: Ordre de les categories de la botiga + open_date: Data d'obertura + close_date: Data de tancament + social: + twitter_placeholder: 'p. ex: @horta_josepribes' + instagram_placeholder: 'p. ex: horta_josepribes' + facebook_placeholder: 'p .ex: www.facebook.com/NomDeLaPàgina' + linkedin_placeholder: 'p. ex: www.linkedin.com/in/ElTeuNom' + stripe_connect: + connect_with_stripe: "Connectar amb Stripe" + stripe_connect_intro: "Per acceptar pagaments amb targeta de crèdit haureu de connectar el vostre compte de Stripe a Open Food Network. Utilitzeu el botó de la dreta per començar. " + stripe_account_connected: "Compte de Stripe connectat." + disconnect: "Desconnecta el compte" + confirm_modal: + title: Connectar amb Stripe + part1: Stripe és un servei de processament de pagaments que permet que les botigues de l'OFN acceptin els pagaments amb targeta de crèdit de les consumidores. + part2: Per utilitzar aquesta funció heu de connectar el vostre compte Stripe a l'OFN. Si feu clic a "Accepto", us redirigirem al lloc web de Stripe on podeu connectar un compte Stripe existent o bé crear-ne un si encara no en teniu cap. + part3: Això permetrà que Open Food Network accepti pagaments amb targeta de crèdit de consumidores en nom vostre. Tingueu en compte que haureu de mantenir el vostre propi compte de Stripe, pagar-ne les tarifes i mantenir el servei a les consumidores pel teu compte. + i_agree: Accepto + cancel: Cancel·lar + tag_rules: + default_rules: + by_default: Per defecte + no_rules_yet: Encara no s'aplica cap regla per defecte + add_new_button: '+ Afegeix una nova regla per defecte' + no_tags_yet: Encara no hi ha cap etiqueta aplicada a aquesta organització + no_rules_yet: Encara no hi ha cap regla aplicada a aquesta etiqueta + for_customers_tagged: 'Per als clients etiquetats:' + add_new_rule: '+ Afegeix una nova regla' + add_new_tag: '+ Afegeix una nova etiqueta' + users: + email_confirmation_notice_html: "La confirmació de correu electrònic està pendent. Hem enviat un correu electrònic de confirmació a %{email}." + resend: Reenviar + owner: 'Propietària' + contact: "Contacte" + contact_tip: "El gestor que rebrà els missatges de correu electrònic de l'organització per a comandes i notificacions. Ha de tenir una adreça electrònica confirmada." + owner_tip: La usuària principal responsable d'aquesta organització. + notifications: Notificacions + notifications_tip: Les notificacions sobre les comandes s'enviaran a aquesta adreça de correu electrònic. + notifications_placeholder: 'p. ex: contacte@hortajosepribes.com' + notifications_note: 'Nota: és possible que hagi de confirmar una nova adreça de correu electrònic abans d''utilitzar-la' + managers: Gestors + managers_tip: Altres usuàries amb permisos per gestionar aquesta organització + invite_manager: "Convida un gestor" + invite_manager_tip: "Convida un usuari no registrat a registrar-se i convertir-se en gestor d'aquesta organització." + add_unregistered_user: "Afegeix un usuari no registrat" + email_confirmed: "S'ha confirmat el correu electrònic" + email_not_confirmed: "Correu electrònic no confirmat" + actions: + edit_profile: Configuració + properties: Propietats + payment_methods: Mètodes de Pagament + payment_methods_tip: Aquesta organització no té mètodes de pagament + shipping_methods: Mètodes d'enviament + shipping_methods_tip: 'Aquesta organització té mètodes d''enviament ' + enterprise_fees: Honoraris de l'organització + enterprise_fees_tip: Aquesta organització no té comissions + admin_index: + name: Nom + role: Rol + sells: Ven + visible: Visible? + owner: Propietària + producer: Productor + change_type_form: + producer_profile: Perfil de productora + connect_ofn: Connecta mitjançant OFN + always_free: SEMPRE GRATUÏT + producer_description_text: Afegeix els teus productes a Katuma i permet a grups de consum i altres organitzacions emmagatzemar els teus productes a les seves botigues. + producer_shop: Botiga d'una productora + sell_your_produce: Ven els teus propis productes + producer_shop_description_text: Ven els teus productes directament les consumidores a través de la teva pròpia botiga a Katuma. + producer_shop_description_text2: Una productora amb botiga només és per als teus productes; si vols vendre productes produïts / cultivats fora del lloc, selecciona "Grup de productores". + producer_hub: Grup de productores + producer_hub_text: Ven productes propis i d'altres productores + producer_hub_description_text: La vostra organització és la columna vertebral del sistema alimentari local. Podeu vendre els vostres propis productes i o afegir els productes d'altres organitzacions a través de la vostra botiga a la Katuma. + profile: Només perfil + get_listing: Apareix en els directoris + profile_description_text: La gent pot trobar-vos i contactar-vos a Katuma. La vostra organització serà visible al mapa i es podrà cercar als directoris. + hub_shop: Botiga + hub_shop_text: Veneu productes d'altres + hub_shop_description_text: La vostra organització la columna vertebral del vostre sistema alimentari local. Afegeix productes d'altres organitzacions i els podreu vendre a través de la vostra botiga a Katuma. + choose_option: Si us plau, trieu una de les opcions anteriors. + change_now: Canvia ara + enterprise_user_index: + loading_enterprises: CARREGANT ORGANITZACIONS + no_enterprises_found: No s'han trobat organitzacions. + search_placeholder: Cerca pel nom + manage: Gestiona + manage_link: Configuració + producer?: "Productora?" + package: "Perfil" + status: "Estat" + new_form: + owner: Propietària + owner_tip: La usuària principal responsable d'aquesta organització. + i_am_producer: Sóc una productora + contact_name: Nom de contacte + edit: + editing: 'Configuració:' + back_link: Tornar a la llista d'organitzacions + new: + title: Nova organització + back_link: 'Tornar a la llista d''organitzacions ' + remove_logo: + remove: "Elimina la imatge" + removed_successfully: "Logotip eliminat correctament" + immediate_removal_warning: "El logotip s'eliminarà immediatament després de confirmar." + remove_promo_image: + remove: "Elimina la imatge " + removed_successfully: "Imatge promocional eliminada correctament" + immediate_removal_warning: "La imatge promocional s'eliminarà immediatament després de confirmar." + welcome: + welcome_title: Benvingut a la Katuma - Open Food Network! + welcome_text: Heu creat correctament un + next_step: Següent pas + choose_starting_point: 'Escull el teu perfil:' + invite_manager: + user_already_exists: "L'usuari ja existeix" + error: "Alguna cosa ha anat malament" + order_cycles: + edit: + advanced_settings: Configuració avançada + update_and_close: Actualitza i tanca + choose_products_from: 'Trieu Productes des de:' + exchange_form: + pickup_time_tip: Quan les comandes d'aquest cicle de comandes estiguin preparades per a les consumidores + pickup_instructions_placeholder: "Instruccions de recollida" + pickup_instructions_tip: Aquestes instruccions es mostraran a les consumidores després d'haver completat una comanda + pickup_time_placeholder: "Preparat per (és a dir, data / hora)" + receival_instructions_placeholder: "Instruccions de recepció" + add_fee: 'Afegeix una comissió' + selected: 'seleccionat' + add_exchange_form: + add_supplier: 'Afegeix proveïdora' + add_distributor: 'Afegeix distribuïdora' + advanced_settings: + title: Configuració avançada + choose_product_tip: Podeu optar per restringir tots els productes disponibles (tant d'entrada com de sortida), per només als que pertanyen a l'inventari de %{inventory}. + preferred_product_selection_from_coordinator_inventory_only_here: Només inventari del coordinador + preferred_product_selection_from_coordinator_inventory_only_all: Tots els productes disponibles + save_reload: Desa i recarrega la pàgina + coordinator_fees: + add: Afegeix una comissió del coordinador + filters: + search_by_order_cycle_name: "Cerca pel nom del Cicle de Comanda..." + involving: "Implica" + any_enterprise: "Qualsevol organització" + any_schedule: "Qualsevol horari" + form: + incoming: Entrant + supplier: Proveïdora + receival_details: Detalls de recepció + fees: Tarifes + outgoing: Sortint + distributor: Distribuïdora + products: Productes + tags: Etiquetes + add_a_tag: Afegeix una etiqueta + delivery_details: Detalls de recollida / lliurament + debug_info: Informació de depuració + index: + schedule: Horari + schedules: Horaris + adding_a_new_schedule: Afegir una nova programació + updating_a_schedule: Actualització una programació + new_schedule: Nova programació + create_schedule: Crear una programació + update_schedule: Actualitza la programació + delete_schedule: Suprimeix la programació + created_schedule: Programació creada + updated_schedule: Programació actualitzada + deleted_schedule: Programació eliminada + schedule_name_placeholder: Nom de la programació + name_required_error: Si us plau introdueix un nom per a aquesta programació + no_order_cycles_error: Si us plau selecciona com a mínim un cicle de comanda (arrossega i deixa anar) + name_and_timing_form: + name: Nom + orders_open: Les comandes s'obren a + coordinator: Coordinador + orders_close: Les comandes tanquen + row: + suppliers: proveïdores + distributors: distribuïdores + variants: variants + simple_form: + ready_for: Preparat per + ready_for_placeholder: Data / hora + customer_instructions: Instruccions de la consumidora + customer_instructions_placeholder: Notes de recollida o de lliurament + products: Productes + fees: Tarifes + destroy_errors: + orders_present: Una consumidora ha seleccionat aquest Cicle de Comanda i no es pot esborrar. Per evitar que les consumidores hi accedeixin, tanqueu-lo. + schedule_present: Aquest cicle de comanda està vinculat a una programació i no es pot esborrar. Desenllaça o suprimeix primer la programació. + bulk_update: + no_data: Hm, alguna cosa ha sortit malament. No s'ha trobat cap cicle de comanda. + date_warning: + msg: Aquest cicle de comanda està enllaçat amb %{n}comandes de subscripció obertes . Si canvieu aquesta data ara això no afectarà comandes que ja s'hagin realitzat però cal evitar-ho si és possible. Esteu segur que voleu continuar? + cancel: Cancel·lar + proceed: Procedeix + producer_properties: + index: + title: Propietats de la productora + proxy_orders: + cancel: + could_not_cancel_the_order: No s'ha pogut cancel·lar la comanda + resume: + could_not_resume_the_order: No es pot reprendre la comanda + shared: + user_guide_link: + user_guide: Guia de l'usuari + overview: + enterprises_header: + ofn_with_tip: Les organitzacions són productores i/o grups de consum i són la unitat bàsica d'organització dins d'Open Food Network. + enterprises_hubs_tabs: + has_no_payment_methods: "%{enterprise} no té mètodes de pagament" + has_no_shipping_methods: "%{enterprise} no té mètodes d'enviament" + has_no_enterprise_fees: "%{enterprise} no té comissions de l'organització" + enterprise_issues: + create_new: Crear nou + resend_email: Reenvia el correu electrònic + has_no_payment_methods: "%{enterprise} actualment no té mètodes de pagament" + has_no_shipping_methods: "%{enterprise} actualment no té mètodes d'enviament" + email_confirmation: "La confirmació de correu electrònic està pendent. Hem enviat un correu electrònic de confirmació a %{email}." + not_visible: "%{enterprise} no és visible i, per tant, no es pot trobar al mapa ni a les cerques" + reports: + hidden: OCULT + unitsize: UNITAT DE MESURA + total: TOTAL + total_items: ARTICLES TOTALS + supplier_totals: Total de de proveïdores del Cicle de Comanda + supplier_totals_by_distributor: Total de proveïdores del Cicle de Comanda - per distribuïdora + totals_by_supplier: Totals de la distribuïdora del Cicle de Comanda - per proveïdora + customer_totals: Totals de consumidores del Cicle de Comanda + all_products: Tots els productes + inventory: Inventari (disponible) + lettuce_share: LettuceShare + mailing_list: Llista de correus electrònics + addresses: Adreces + payment_methods: Informe de Mètodes de pagament + delivery: Informe de lliurament + tax_types: Tipus d'impostos + tax_rates: Tarifes fiscals + pack_by_customer: Paquets per consumidora + pack_by_supplier: Paquets per proveïdora + orders_and_distributors: + name: Comandes i distribuïdores + description: Comandes amb detalls de la distribuïdora + payments: + name: Informes de pagament + description: Informes per a pagaments + orders_and_fulfillment: + name: Informes de comandes i compliment + customers: + name: Consumidores + products_and_inventory: + name: Productes & Inventari + sales_total: + name: Total de vendes + description: Total de vendes per a totes les comandes + users_and_enterprises: + name: Usuaris & Organitzacions + description: Propietat i estatus de l'organització + order_cycle_management: + name: Gestió del Cicle de Comanda + sales_tax: + name: Impostos sobre la venda + xero_invoices: + name: Factures Xero + description: Factures per a la importació a Xero + packing: + name: Informes d'embalatge + subscriptions: + subscriptions: Subscripcions + new: Nova subscripció + create: Crea una subscripció + index: + please_select_a_shop: Si us plau, seleccioneu una botiga + edit_subscription: Edita la subscripció + pause_subscription: Pausa la subscripció + unpause_subscription: Reprèn la subscripció + cancel_subscription: Cancel·la la subscripció + setup_explanation: + just_a_few_more_steps: 'Només uns quants passos més abans de començar:' + enable_subscriptions: "Activa les subscripcions d'almenys una de les teves botigues" + enable_subscriptions_step_1_html: 1. Aneu a la pàgina %{enterprises_link}, cerqueu la vostra botiga i feu clic a "Gestionar" + enable_subscriptions_step_2: 2. A "Preferències de la botiga", activeu l'opció Subscripcions + set_up_shipping_and_payment_methods_html: Configureu els mètodes %{shipping_link} i %{payment_link} + set_up_shipping_and_payment_methods_note_html: Tingueu en compte que només es poden utilitzar mètodes de pagament en efectiu i Stripe amb les subscripcions + ensure_at_least_one_customer_html: Assegureu-vos que hi hagi almenys un %{customer_link} + create_at_least_one_schedule: Crea almenys una programació + create_at_least_one_schedule_step_1_html: 1. Aneu a la pàgina %{order_cycles_link} + create_at_least_one_schedule_step_2: 2. Creeu un Cicle de Comanda si encara no ho heu fet + create_at_least_one_schedule_step_3: 3. Fes clic a '+ Nova programació' i omple el formulari + once_you_are_done_you_can_html: Un cop hagueu acabat, podeu %{reload_this_page_link} + reload_this_page: tornar a carregar aquesta pàgina + steps: + details: 1. Detalls bàsics + address: 2. Adreça + products: 3. Afegeix productes + review: 4. Revisa i desa + subscription_line_items: + this_is_an_estimate: | + Els preus que es mostren són només una estimació i es calculen en el moment en què es canvia la subscripció. + Si canvieu els preus o les comissions, les comandes s'actualitzaran, però la subscripció continuarà mostrant els valors anteriors. + details: + details: Detalls + invalid_error: Oops! Si us plau ompliu tots els camps obligatoris ... + allowed_payment_method_types_tip: Actualment només es poden utilitzar els mètodes de pagament en efectiu i Stripe + credit_card: Targeta de crèdit + charges_not_allowed: Els càrrecs no estan permesos per aquesta consumidora + no_default_card: La consumidora no té targetes disponibles per cobrar + card_ok: La consumidora té una targeta disponible per cobrar + loading_flash: + loading: CARREGANT SUBSCRIPCIONS + review: + details: Detalls + address: Adreça + products: 'Productes ' + product_already_in_order: Aquest producte ja s'ha afegit a la comanda. Editeu-ne la quantitat directament. + orders: + number: Número + confirm_edit: Estàs segur que voleu editar aquesta comanda? Si ho fas és més difícil que es sincronitzin automàticament els canvis a la subscripció en el futur. + confirm_cancel_msg: Estàs segur que vols cancel·lar aquesta subscripció? Aquesta acció no es pot desfer. + cancel_failure_msg: 'Ho sentim, la cancel·lació ha fallat!' + confirm_pause_msg: Estàs segur que vols pausar aquesta subscripció? + pause_failure_msg: 'Ho sentim, la pausa ha fallat!' + confirm_unpause_msg: Estàs segur que vols reprendre aquesta subscripció? + unpause_failure_msg: 'Ho sentim, la represa ha fallat!' + confirm_cancel_open_orders_msg: "Actualment hi ha algunes comandes obertes per a aquesta subscripció. Ja s'ha notificat a les consumidores que les comandes serà atesa. Voleu cancel·lar aquestes comandes o conservar-les?" + resume_canceled_orders_msg: "Algunes comandes d'aquesta subscripció es poden reprendre ara mateix. Podeu reprendre-les des del menú desplegable de comandes." + yes_cancel_them: Cancel·lar-les + no_keep_them: Conservar-les + yes_i_am_sure: Sí, n'estic segur + order_update_issues_msg: Algunes comandes no s'han pogut actualitzar automàticament, probablement perquè s'han editat manualment. Reviseu els problemes que es detallen a continuació i realitzeu els ajustaments a comandes individuals si és necessari. + no_results: + no_subscriptions: Encara no hi ha cap subscripció... + why_dont_you_add_one: Per què no n'afegiu un? :) + no_matching_subscriptions: No s'han trobat subscripcions coincidents + schedules: + destroy: + associated_subscriptions_error: Aquesta programació no es pot suprimir perquè té subscripcions associades + controllers: + enterprises: + stripe_connect_cancelled: "S'ha cancel·lat la connexió a Stripe" + stripe_connect_success: "S'ha connectat correctament el compte de Stripe" + stripe_connect_fail: Ho sentim, s'ha produït un error en la connexió del vostre compte de Stripe + stripe_connect_settings: + resource: 'Configuració de la connexió amb Stripe ' + api: + enterprise_logo: + destroy_attachment_does_not_exist: "El logotip no existeix" + enterprise_promo_image: + destroy_attachment_does_not_exist: "La imatge promocional no existeix" + checkout: + already_ordered: + cart: "cistella" + message_html: "Ja teniu una comanda per a aquest cicle de comanda. Consulteu %{cart} per veure els articles que heu demanat anteriorment. També podeu cancel·lar articles sempre que el cicle de comanda estigui obert." + shops: + hubs: + show_closed_shops: "Mostra les botigues tancades" + hide_closed_shops: "Amaga les botigues tancades" + show_on_map: "Mostra-ho tot al mapa" + shared: + menu: + cart: + checkout: "Validar ara" + already_ordered_products: "Ja està demanat en aquest cicle de comanda" + register_call: + selling_on_ofn: "Estàs interessat en formar part d'Open Food Network?" + register: "Registra't aquí" + footer: + footer_global_headline: "OFN Global" + footer_global_home: "Inici" + footer_global_news: "Notícies" + footer_global_about: "Sobre" + footer_global_contact: "Contacte" + footer_sites_headline: "Pàgines d'OFN" + footer_sites_developer: "Desenvolupador" + footer_sites_community: "Comunitat" + footer_sites_userguide: "Guia de l'usuari" + footer_secure: "Segur i de confiança." + footer_secure_text: "Open Food Network utilitza el xifrat SSL (RSA de 2048 bits) a tot arreu per mantenir les vostres dades comercials i de pagament privades. Els nostres servidors no emmagatzemen els detalls de la targeta de crèdit i els pagaments es processen mitjançant serveis compatibles amb PCI." + footer_contact_headline: "Mantén el contacte" + footer_contact_email: "Envia'ns un correu electrònic" + footer_nav_headline: "Navega" + footer_join_headline: "Uneix-te a nosaltres" + footer_join_body: "Crea un directori de botigues a grups a Open Food Network." + footer_join_cta: "Vull saber-ne més!" + footer_legal_call: "Llegiu el nostre" + footer_legal_tos: "Termes i condicions" + footer_legal_visit: "Troba'ns a" + footer_legal_text_html: "Open Food Network és una plataforma de programari lliure i de codi obert. El nostre contingut està llicenciat amb %{content_license} i el nostre codi amb %{code_license}." + footer_data_text_with_privacy_policy_html: "Tenim cura de les vostres dades. Vegeu les nostres %{privacy_policy} i %{cookies_policy}" + footer_data_text_without_privacy_policy_html: "Tenim cura de les vostres dades. Vegeu la nostra %{cookies_policy}" + footer_data_privacy_policy: "política de privacitat" + footer_data_cookies_policy: "política de cookies" + footer_skylight_dashboard_html: Les dades de rendiment estan disponibles a %{dashboard}. + shop: + messages: + login: "Inicia sessió" + register: "Registra't" + contact: "contacta" + require_customer_login: "Aquesta botiga només és per a consumidores registrades." + require_login_html: "Si us plau%{login} si ja teniu un compte. En cas contrari, %{register} per convertir-vos en una consumidora." + require_customer_html: "Si us plau%{contact} %{enterprise} per convertir-vos en consumidora." + card_could_not_be_updated: No s'ha pogut actualitzar la targeta + card_could_not_be_saved: no s'ha pogut desar la targeta + spree_gateway_error_flash_for_checkout: "Hi ha hagut un problema amb la vostra informació de pagament: %{error}" + invoice_billing_address: "Adreça de facturació:" + invoice_column_price: "Preu" + invoice_column_item: "Article" + invoice_column_qty: "Quantitat" + invoice_column_unit_price_with_taxes: "Preu unitari (IVA inclòs)" + invoice_column_unit_price_without_taxes: "Preu unitari (impost exclòs)" + invoice_column_price_with_taxes: "Preu total (IVA inclòs)" + invoice_column_price_without_taxes: "Preu total (sense impostos)" + invoice_column_tax_rate: "Taxa d'impost" + tax_invoice: "FACTURA D'IMPOSTOS" + tax_total: "Impost total (%{rate}):" + total_excl_tax: "Total (impostos exclòs):" + total_incl_tax: "Total (impost inclòs):" + invoice_issued_on: "Factura emesa el:" + order_number: "Nombre de factura:" + date_of_transaction: "Data de la transacció:" + ticket_column_qty: "Quantitat" + ticket_column_item: "Article" + ticket_column_unit_price: "Preu unitari" + ticket_column_total_price: "Preu total" + menu_1_title: "Botigues" + menu_1_url: "/shops" + menu_2_title: "Mapa" + menu_2_url: "/map" + menu_3_title: "Productors" + menu_3_url: "/producers" + menu_4_title: "Grups" + menu_4_url: "/groups" + menu_5_title: "Sobre" + menu_5_url: " " + menu_6_title: "Connecta" + menu_6_url: " " + menu_7_title: "Aprèn" + menu_7_url: " " + logo: "Logotip (640x130)" + logo_mobile: "Logotip mòbil (75x26)" + logo_mobile_svg: "Logotip per mòbil (SVG)" + home_hero: " " + home_show_stats: "Mostra estadístiques" + footer_logo: "Logotip (220x76)" + footer_facebook_url: "URL de Facebook" + footer_twitter_url: "URL de Twitter" + footer_instagram_url: "URL d'Instagram" + footer_linkedin_url: "URL de LinkedIn" + footer_googleplus_url: "URL de Google Plus" + footer_pinterest_url: "URL de Pinterest" + footer_email: "Correu electrònic" + footer_links_md: "Enllaços" + footer_about_url: "Quant a l'URL" + user_guide_link: "Enllaç a la guia de l'usuari" + name: Nom + first_name: Nom + last_name: Cognoms + email: Correu electrònic + phone: Telèfon + next: Següent + address: Adreça + address_placeholder: 'p. ex: Carrer Ample, 123' + address2: Adreça (continua) + city: Municipi + city_placeholder: 'p. ex: Sitges' + postcode: Codi postal + postcode_placeholder: 'p. ex: 08870' + state: Estat + country: País + unauthorized: No autoritzat + terms_of_service: "Termes del servei" + on_demand: Sota demanda + none: No productora + not_allowed: No permès + no_shipping: sense mètodes d'enviament + no_payment: sense mètodes de pagament + no_shipping_or_payment: sense mètodes d'enviament o pagament + unconfirmed: no confirmat + days: dies + label_shop: "Botiga" + label_shops: "Botigues" + label_map: "Mapa" + label_producer: "Productora" + label_producers: "Productors" + label_groups: "Grups" + label_about: "Sobre" + label_connect: "Connecta" + label_learn: "Aprèn" + label_blog: "Blog" + label_support: "Suport" + label_shopping: "Compres" + label_login: "Inicia sessió" + label_logout: "Tanca sessió" + label_signup: "Registra't" + label_administration: "Administració" + label_admin: "Administradora" + label_account: "Compte" + label_more: "Mostrar més" + label_less: "Mostra menys" + label_notices: "Avisos" + cart_items: "articles" + cart_headline: "La teva cistella" + total: "Total" + cart_updating: "Actualitzant la cistella..." + cart_empty: "Cistella buida" + cart_edit: "Edita la teva cistella" + card_number: Número de targeta + card_securitycode: "Codi de seguretat" + card_expiry_date: Data de caducitat + card_masked_digit: "X" + new_credit_card: "Nova targeta de crèdit" + my_credit_cards: Les meves targetes de crèdit + add_new_credit_card: 'Afegeix una nova targeta de crèdit ' + saved_cards: Targetes desades + add_a_card: Afegeix una targeta + add_card: Afegeix targeta + you_have_no_saved_cards: Encara no heu guardat cap targeta + saving_credit_card: Desant la targeta de crèdit... + card_has_been_removed: "S'ha eliminat la teva targeta (número: %{number})" + card_could_not_be_removed: Ho sentim, no s'ha pogut eliminar la targeta + ie_warning_headline: "El vostre navegador no està actualitzat :-(" + ie_warning_text: "Per obtenir la millor experiència a Open Food Network et recomanem que actualitzis el teu navegador:" + ie_warning_chrome: Descarrega Chrome + ie_warning_firefox: Descarrega Firefox + ie_warning_ie: Actualitza Internet Explorer + ie_warning_other: "No pots actualitzar el navegador? Prova Open Food Network al telèfon mòbil :-)" + legal: + cookies_policy: + header: "Com utilitzem les cookies" + desc_part_1: "Les cookies són fitxers de text molt petits que s'emmagatzemen a l'ordinador quan visites alguns llocs web." + desc_part_2: "A OFN som plenament respectuosos amb la teva privadesa. Utilitzem només les cookies que són necessàries per oferir-te el servei de compra/venda d'aliments en línia. No venem cap de les vostres dades. En el futur, podríem proposar que compartiu algunes de les vostres dades per crear serveis públics nous que poguessin ser útils per a l'ecosistema (com ara serveis de logística per als sistemes d'alimentació curts), però encara no hi estem treballant i no ho farem sense la vostra autorització :-)" + desc_part_3: "Utilitzem cookies principalment per recordar qui ets si inicies la sessió al servei o per recordar els elements que heu introduït a la vostra cistella encara que no hàgiu iniciat la sessió. Si continues navegant al lloc web sense fer clic \"Acceptar cookies\", suposem que ens estàs donant el consentiment per emmagatzemar les cookies que són essencials per al funcionament del lloc web. Aquí tens la llista de les cookies que fem servir." + essential_cookies: "Cookies essencials" + essential_cookies_desc: "Les cookies següents són estrictament necessàries per al funcionament del nostre lloc web." + essential_cookies_note: "La majoria de les cookies només contenen un identificador únic, però no hi ha altres dades, de manera que la teva adreça de correu electrònic i contrasenya, per exemple, mai no estan contingudes ni exposades." + cookie_domain: "Establert per:" + cookie_session_desc: "S'utilitza per permetre que el lloc web recordi usuaris entre visites a la pàgina, per exemple, recordar els articles de la vostra cistella." + cookie_consent_desc: "S'utilitza per mantenir l'estat del consentiment de l'usuari per emmagatzemar cookies" + cookie_remember_me_desc: "S'utilitza si l'usuari ha demanat que el lloc web el recordi. Aquesta cookie s'elimina automàticament després de 12 dies. Si com a usuari voleu que se suprimeixi aquesta cookie, només heu de tancar la sessió. Si no voleu que aquesta cookie s'instal·li a l'ordinador, no haureu de marcar la casella \"Recorda'm\" quan inicieu la sessió." + cookie_openstreemap_desc: "Utilitzat pel nostre proveïdor de confiança d'emmagatzematge de codi obert (OpenStreetMap) per garantir que no rebis massa sol·licituds durant un període de temps determinat, per evitar l'abús dels seus serveis." + cookie_stripe_desc: "Dades recollides pel nostre processador de pagaments Stripe per a la detecció de frau https://stripe.com/cookies-policy/legal. No totes les botigues utilitzen Stripe com a mètode de pagament, però és una bona pràctica per evitar que el frau s'apliqui a totes les pàgines. Probablement Stripe construeixi una imatge de quines de les nostres pàgines solen interactuar amb la seva API i, a continuació, marca qualsevol cosa inusual. Així, configurar les cookies d'Stripe té una funció més àmplia que simplement proporcionar un mètode de pagament a un usuari. Eliminant-lo podria afectar la seguretat del propi servei. Pots obtenir més informació sobre Stripe i llegir la seva política de privadesa a https://stripe.com/privacy." + statistics_cookies: "Cookies d'estadístiques" + statistics_cookies_desc: "Les següents no són estrictament necessàries, però ajuden a proporcionar-vos la millor experiència d'usuari, permetent-nos analitzar el comportament de l'usuari, identificar quines funcions s'utilitzen més o quines no es fan servir, comprendre problemes d'experiència d'usuari, etc." + statistics_cookies_analytics_desc_html: "Per recopilar i analitzar les dades d'ús de la plataforma utilitzem Google Analytics ja que era el servei predeterminat connectat amb Spree (el programari de codi obert de comerç en línia que hem construït), però la nostra visió és canviar a Matomo (ex Piwik, eina d'anàlisi de codi obert compatible amb GDPR i protegeix la vostra privadesa) tan aviat com puguem. " + statistics_cookies_matomo_desc_html: "Per recopilar i analitzar les dades d'ús de la plataforma, utilitzem Matomo (ex Piwik), una eina d'anàlisi de codi obert que és compatible amb GDPR i protegeix la vostra privadesa." + statistics_cookies_matomo_optout: "Vols desactivar l'anàlisi de dades de Matomo? No recopilem cap dada personal i Matomo ens ajuda a millorar el nostre servei però respectem la teva elecció :-)" + cookie_analytics_utma_desc: "S'utilitza per distingir usuaris i sessions. La cookie es crea quan s'executa la biblioteca javascript i no existeixen cookies __utma existents. La cookie s'actualitza cada cop que s'envien dades a Google Analytics." + cookie_analytics_utmt_desc: "S'utilitza per accelerar la quantitat de sol·licituds." + cookie_analytics_utmb_desc: "S'utilitza per determinar noves sessions / visites. La cookie es crea quan s'executa la biblioteca javascript i no existeixen cookies __utmb existents. La cookie s'actualitza cada cop que s'envien dades a Google Analytics." + cookie_analytics_utmc_desc: "No s'utilitza a ga.js. Estableix la interoperabilitat amb urchin.js. Històricament, aquesta cookie funcionava juntament amb la cookie __utmb per determinar si l'usuari estava en una nova sessió / visita." + cookie_analytics_utmz_desc: "Emmagatzema l'origen del codi o la campanya que explica com l'usuari ha arribat al vostre lloc. La cookie es crea quan s'executa i s'actualitza la biblioteca javascript cada vegada que s'envien dades a Google Analytics." + cookie_matomo_basics_desc: "Les primeres cookies de Matomo per recollir estadístiques." + cookie_matomo_ignore_desc: "La cookie usada per excloure l'usuari de ser seguit." + disabling_cookies_header: "Advertència sobre la desactivació de cookies" + disabling_cookies_desc: "Com a usuari sempre podeu permetre, bloquejar o eliminar les cookies Open Food Network o qualsevol altra pàgina web sempre que vulgueu mitjançant el control de configuració del vostre navegador. Cada navegador té una operativa diferent. Aquests són els enllaços:" + disabling_cookies_firefox_link: "https://support.mozilla.org/en-US/kb/enable-and-disable-cookies-website-preferences" + disabling_cookies_chrome_link: "https://support.google.com/chrome/answer/95647" + disabling_cookies_ie_link: "https://support.microsoft.com/en-us/help/17442/windows-internet-explorer-delete-manage-cookies" + disabling_cookies_safari_link: "https://www.apple.com/legal/privacy/en-ww/cookies/" + disabling_cookies_note: "Però tingueu en compte que si suprimiu o modifiqueu les cookies essencials que utilitza Open Food Network el lloc web no funcionarà; no podreu afegir res a la vostra cistella ni per validar la compra, per exemple." + cookies_banner: + cookies_usage: "Aquest lloc utilitza cookies per fer que la teva navegació no sigui problemàtica i sigui segura i ens ajuda a comprendre com navegues per millorar les funcions que oferim." + cookies_definition: "Les cookies són fitxers de text molt petits que s'emmagatzemen a l'ordinador quan visites alguns llocs web." + cookies_desc: "Utilitzem només les cookies que són necessàries per oferir-te el servei de compra/venda d'aliments en línia. No venem cap de les teves dades. Utilitzem cookies principalment per recordar qui ets si inicies la sessió al servei o per recordar els elements que has introduït a la teva cistella encara que no hagis iniciat la sessió. Si continues navegant al lloc web sense fer clic \"Acceptar cookies\", suposem que ens estàs donant el consentiment per emmagatzemar les cookies que són essencials per al funcionament del lloc web." + cookies_policy_link_desc: "Si vols obtenir més informació consulta la nostra" + cookies_policy_link: "política de cookies" + cookies_accept_button: "Acceptar les cookies" + home_shop: Compra ara + brandstory_headline: "Aliments de proximitat." + brandstory_intro: "De vegades la millor manera de solucionar el sistema és començar una nova ..." + brandstory_part1: "Comencem des del principi. Amb agricultores i productores disposades a explicar les seves històries amb orgull i veritat. Amb les distribuïdores disposades a connectar persones amb productes de manera justa i honesta. Amb les consumidores que creuen que millors decisions de compra setmanals poden seriosament canviar el món." + brandstory_part2: "A continuació, necessitem una manera de fer-ho real. Una forma d'empoderar a totes les que cultiven, venen i compren aliments. Una manera d'explicar totes les històries, de gestionar tota la logística. Una manera de convertir la transacció en transformació cada dia." + brandstory_part3: "Així que construïm un mercat en línia que anivella el camp de joc. És transparent, de manera que crea relacions reals. És de codi obert, de manera que és propietat de tothom. Escala a regions i nacions, de manera que les persones comencin versions a tot el món." + brandstory_part4: "Funciona a tot arreu. Ho canvia tot." + brandstory_part5_strong: "L'anomenem Open Food Network." + brandstory_part6: "A totes ens agrada menjar. Ara també podem estimar el nostre sistema alimentari." + learn_body: "Explora models, històries i recursos per ajudar-te a desenvolupar el teu negoci o organització de menjar just. Troba coneixements, esdeveniments i altres oportunitats d'aprendre dels companys." + learn_cta: "Inspira't" + connect_body: "Busca els nostres directoris complets de productores, organitzacions o xarxes per trobar menjar de proximitat i just a prop teu. Indica i llista la teva empresa o organització a l'OFN perquè les consumidores et trobin. Uneix-te a la comunitat per obtenir consells i resoldre problemes juntes." + connect_cta: "Explorar" + system_headline: "Compres - aquí t'expliquem com funcionen." + system_step1: "1. Cerca" + system_step1_text: "Cerca a les nostres botigues diverses i independents per menjar producte local de temporada. Cerca per població i categoria d'aliments, o bé si prefereixes lliurament o recollida." + system_step2: "2. Compra" + system_step2_text: "Transforma les teves transaccions amb aliments locals assequibles de diverses productores i grups. Coneix les històries del teu menjar i de les persones que el fan!" + system_step3: "3. Recollida / lliurament" + system_step3_text: "Espereu per al vostre lliurament o visiteu la vostra productora o grup de consum per tenir una connexió més personal amb el vostre menjar. La compra d'aliments és tan diversa com ens proposem." + cta_headline: "Compres que fan del món un lloc millor." + cta_label: "Estic preparada" + stats_headline: "Estem creant un nou sistema alimentari." + stats_producers: "productores d'aliments" + stats_shops: "botigues d'alimentació" + stats_shoppers: "consumidores d'aliments" + stats_orders: "comandes de menjar" + checkout_title: Realitza la comanda + checkout_now: Validar ara + checkout_order_ready: Comanda preparada per + checkout_hide: Amaga + checkout_expand: Expandeix + checkout_headline: "Estàs preparada per validar la compra?" + checkout_as_guest: "Realitzar comanda com a convidat" + checkout_details: "Els teus detalls" + checkout_billing: "Informació de facturació" + checkout_default_bill_address: "Desa per defecte com a adreça de facturació " + checkout_shipping: Informació d'enviament + checkout_default_ship_address: "Desa per defecte com a adreça d'enviament " + checkout_method_free: Gratuït + checkout_address_same: L'adreça d'enviament és igual que l'adreça de facturació? + checkout_ready_for: "Preparat per:" + checkout_instructions: "Alguns comentaris o instruccions especials?" + checkout_payment: Pagament + checkout_send: Realitza una comanda ara + checkout_your_order: La teva comanda + checkout_cart_total: Total de la cistella + checkout_shipping_price: Enviament + checkout_total_price: Total + checkout_back_to_cart: "Tornar a la cistella" + cost_currency: "Moneda del cost" + order_paid: PAGAT + order_not_paid: NO PAGAT + order_total: Total de la comanda + order_payment: "Pagament a través de:" + order_billing_address: Adreça de facturació + order_delivery_on: Lliurament a + order_delivery_address: Adreça de lliurament + order_delivery_time: Hora de lliurament + order_special_instructions: "Les teves notes:" + order_pickup_time: Llest per a la recollida + order_pickup_instructions: Instruccions de recollida + order_produce: Productes + order_total_price: Total + order_includes_tax: (inclou impostos) + order_payment_paypal_successful: El teu pagament mitjançant PayPal s'ha processat correctament. + order_hub_info: Informació del grup + order_back_to_store: Tornar a la botiga + order_back_to_cart: Tornar a la cistella + bom_tip: "Utilitzeu aquesta pàgina per alterar les quantitats de productes en diverses comandes. Els productes també es poden eliminar de les comandes completament, si es requereix." + unsaved_changes_warning: "Hi ha canvis sense desar i es perdran si continueu." + unsaved_changes_error: "Els camps amb vora vermella contenen errors." + products: "Productes" + products_in: "en %{oc}" + products_at: "a %{distributor}" + products_elsewhere: "Productes trobats en altres llocs" + email_welcome: "Benvinguda" + email_confirmed: "Gràcies per confirmar la teva adreça de correu electrònic." + email_registered: "ara forma part de" + email_userguide_html: "La Guia d'usuari amb suport detallat per configurar una productora o grup de consum és aquí: %{link}" + email_admin_html: "Pots gestionar el teu compte iniciant sessió a l'%{link} o fent clic a la pestanya a la part superior dreta de la pàgina d'inici i seleccionant Administració." + email_community_html: "També tenim un fòrum en línia per debats de la comunitat relacionada amb el programari OFN i els desafiaments únics de triar endavant una organització alimentària. T'animem a unir-t'hi. Estem en constant evolució i les teves contribucions en aquest fòrum donaran forma al que passi a en el futur. %{link}" + join_community: "Uneix-te a la comunitat" + email_confirmation_activate_account: "Abans de poder activar el compte nou hem de confirmar la teva adreça de correu electrònic." + email_confirmation_greeting: "Hola, %{contact}!" + email_confirmation_profile_created: "S'ha creat exitosament un perfil per %{name}. Per activar el teu perfil hem de confirmar aquesta adreça de correu electrònic." + email_confirmation_click_link: "Si us plau fes clic a l'enllaç següent per confirmar el teu correu electrònic i continuar configurant el teu perfil." + email_confirmation_link_label: "Confirma aquesta adreça de correu electrònic »" + email_confirmation_help_html: "Després de confirmar el teu correu electrònic podràs accedir al teu compte d'administració per a aquesta organització. Consulta l'%{link} per obtenir més informació sobre les funcions d'%{sitename} i començar a utilitzar el teu perfil o botiga en línia." + email_confirmation_notice_unexpected: "Has rebut aquest missatge perquè t'has inscrit a l'%{sitename} o perquè has estat convidada a registrar-te per algú que probablement coneixes. Si no entens per què estàs rebent aquest correu electrònic, escriu a %{contact}." + email_social: "Connecta't amb nosaltres:" + email_contact: "Envia'ns un correu electrònic:" + email_signoff: "Salut," + email_signature: "Equip %{sitename}" + email_confirm_customer_greeting: "Hola %{name}," + email_confirm_customer_intro_html: "Gràcies per comprar a %{distributor} ." + email_confirm_customer_number_html: "Confirmació de la comanda # %{number} " + email_confirm_customer_details_html: "Aquests són els detalls de la teva comanda de %{distributor} :" + email_confirm_customer_signoff: "Salutacions cordials," + email_confirm_shop_greeting: "Hola %{name}," + email_confirm_shop_order_html: "Que bé! Tens una nova comanda per %{distributor} ." + email_confirm_shop_number_html: "Confirmació de la comanda # %{number} " + email_order_summary_item: "Article" + email_order_summary_quantity: "quant." + email_order_summary_price: "Preu" + email_order_summary_subtotal: "Subtotal:" + email_order_summary_total: "Total:" + email_order_summary_includes_tax: "(inclou impostos):" + email_payment_paid: PAGAT + email_payment_not_paid: 'NO PAGAT ' + email_payment_summary: Resum del pagament + email_payment_method: "Pagament a través de:" + email_so_placement_intro_html: "Tens una nova comanda amb %{distributor} " + email_so_placement_details_html: "Aquests són els detalls de la comanda de %{distributor} :" + email_so_placement_changes: "Malauradament, no tots els productes que has demanat estaven disponibles. Les quantitats originals que has sol·licitat apareixen ratllades a sota." + email_so_payment_success_intro_html: "S'ha processat un pagament automàtic per a la vostra comanda des de %{distributor} ." + email_so_placement_explainer_html: "Aquesta comanda s'ha creat automàticament per tu." + email_so_edit_true_html: "Potd fer canvis fins que les comandes es tanquin el %{orders_close_at}." + email_so_edit_false_html: "Pots veure detalls d'aquesta comanda en qualsevol moment." + email_so_contact_distributor_html: "Si tens alguna pregunta pots contactar amb %{distributor} a través d'%{email}." + email_so_contact_distributor_to_change_order_html: "Aquesta comanda s'ha creat automàticament per a vostè. Podeu fer canvis fins que les comandes es tanquin a %{orders_close_at} contactant a %{distributor} a través d'%{email}." + email_so_confirmation_intro_html: "La teva comanda amb %{distributor} ja està confirmada" + email_so_confirmation_explainer_html: "Vas realitzar aquesta comanda automàticament i ara s'ha finalitzat." + email_so_confirmation_details_html: "A continuació trobareu tot el que necessiteu saber sobre la comanda de %{distributor} :" + email_so_empty_intro_html: "Hem intentat fer una nova comanda amb %{distributor} , però hem tingut alguns problemes..." + email_so_empty_explainer_html: "Malauradament, cap dels productes que heu demanat estava disponible, de manera que no s'ha realitzat cap comanda. Les quantitats originals que heu sol·licitat apareixen ratllades a sota." + email_so_empty_details_html: "Aquests són els detalls de la comanda sense confirmar per %{distributor} :" + email_so_failed_payment_intro_html: "Hem intentat processar un pagament però hem tingut alguns problemes..." + email_so_failed_payment_explainer_html: "El pagament de la vostra subscripció amb %{distributor} ha fallat a causa d'un problema amb la vostra targeta de crèdit. S'ha notificat a %{distributor} d'aquest pagament fallit." + email_so_failed_payment_details_html: "Aquests són els detalls de l'error proporcionats per la passarel·la de pagament:" + email_shipping_delivery_details: Detalls de lliurament + email_shipping_delivery_time: "Lliurament a:" + email_shipping_delivery_address: "Adreça de lliurament:" + email_shipping_collection_details: Detalls de la recollida + email_shipping_collection_time: "Llest per a la recollida:" + email_shipping_collection_instructions: "Instruccions de recollida:" + email_special_instructions: "Les teves notes: " + email_signup_greeting: Hola! + email_signup_welcome: "Benvinguda a %{sitename}!" + email_signup_confirmed_email: "Gràcies per confirmar el teu correu electrònic." + email_signup_shop_html: "Ara pots iniciar sessió a %{link}." + email_signup_text: "Gràcies per unir-te a la xarxa. Si ets una consumidora, esperem presentar-te a moltes agricultores fantàstiques, meravellosos grups de consum i aliments deliciosos. Si ets una productora o organització alimentària, ens complau tenir-te com a part de la xarxa." + email_signup_help_html: "Donem la benvinguda a totes les teves preguntes i comentaris; pots utilitzar el botó Enviar comentaris del lloc web o enviar-nos un correu electrònic a %{email}" + invite_email: + greeting: "Hola! " + invited_to_manage: "Has estat convidada a gestionar %{enterprise} a %{instance}." + confirm_your_email: "Hauries d'haver rebut o aviat rebràs un correu electrònic amb un enllaç de confirmació. No podràs accedir al perfil de%{enterprise} fins que no hagis confirmat el teu correu electrònic." + set_a_password: "A continuació se us demanarà que configureu una contrasenya abans de poder administrar l'organització." + mistakenly_sent: "No esteu segur de perquè heu rebut aquest correu electrònic? Poseu-vos en contacte amb %{owner_email} per obtenir més informació." + producer_mail_greeting: "Benvolguda" + producer_mail_text_before: "Ara tenim totes les comandes per al proper repartiment." + producer_mail_order_text: "Aquí tens un resum de les comandes dels teus productes:" + producer_mail_delivery_instructions: "Instruccions de recollida / lliurament d'estoc:" + producer_mail_signoff: "Gràcies i els millors desitjos" + shopping_oc_closed: Les comandes estan tancades + shopping_oc_closed_description: "Si us plau espera fins que s'obri el pròxim cicle (o posa't en contacte amb nosaltres directament per veure si podem acceptar alguna comanda fora de temps)" + shopping_oc_last_closed: "L'últim cicle va tancar fa %{distance_of_time} " + shopping_oc_next_open: "El següent cicle s'obre en %{distance_of_time}" + shopping_tabs_about: "Sobre %{distributor}" + shopping_tabs_contact: "Contacte" + shopping_contact_address: "Adreça" + shopping_contact_web: "Contacte" + shopping_contact_social: "Segueix" + shopping_groups_part_of: "forma part de:" + shopping_producers_of_hub: "Productores de%{hub}:" + enterprises_next_closing: "Tancament de la comanda següent" + enterprises_ready_for: "Preparat per" + enterprises_choose: "Escull quan vols la teva comanda:" + maps_open: "Obert" + maps_closed: "Tancat" + hubs_buy: "Compreu per:" + hubs_shopping_here: "Compra aquí" + hubs_orders_closed: "Comandes tancades" + hubs_profile_only: "Només perfil" + hubs_delivery_options: "Opcions de lliurament" + hubs_pickup: "Recollida" + hubs_delivery: "Lliurament" + hubs_producers: "Les nostres productores" + hubs_filter_by: "Filtra per" + hubs_filter_type: "Tipus" + hubs_filter_delivery: "Lliurament " + hubs_filter_property: "Propietat" + hubs_matches: "Volies dir?" + hubs_intro: 'Compreu a la vostra zona ' + hubs_distance: El més proper a + hubs_distance_filter: "Mostra'm botigues properes a%{location}" + shop_changeable_orders_alert_html: + one: El vostre ordre amb %{shop} / %{order} està obert per a la seva revisió. Podeu fer canvis fins %{oc_close}. + other: Tens %{count} comandes amb %{shop} actualment obertes per a la seva revisió. Pots fer canvis fins %{oc_close}. + orders_changeable_orders_alert_html: S'ha confirmat aquesta comanda però pots fer-hi canvis fins a %{oc_close} . + products_clear_all: Esborra-ho tot + products_showing: "S'està mostrant:" + products_with: amb + products_search: "Cerca per producte o productora" + products_loading: "S'estan carregant els productes..." + products_updating_cart: "Actualitzant la cistella..." + products_cart_empty: "Cistella buida" + products_edit_cart: "Edita la teva cistella" + products_from: de + products_change: "No hi ha canvis per desar." + products_update_error: "No s'ha pogut desar pel(s) següent(s) error(s):" + products_update_error_msg: "S'ha produït un error en desar." + products_update_error_data: "S'ha produït un error en desar a causa de dades no vàlides:" + products_changes_saved: "S'han desat els canvis." + search_no_results_html: "Ho sentim, no s'ha trobat cap resultat per %{query}. Intentar una altra cerca?" + components_profiles_popover: "Els perfils no tenen una botiga a l'OFN però poden tenir la seva pròpia botiga física o en línia en altres llocs" + components_profiles_show: "Mostra els perfils" + components_filters_nofilters: "Sense filtres" + components_filters_clearfilters: "Esborra tots els filtres" + groups_title: Grups + groups_headline: Xarxes / regions + groups_text: "Cada productora és única. Tots els negocis tenen alguna cosa diferent per oferir. Els nostres grups són col·lectius o xarxes de productores, grups de consum o distribuïdores que comparteixen alguna cosa comú com la ubicació, la parada en un mercat de pagès o la filosofia. Això fa que la teva experiència de compra sigui més fàcil. Explora els nostres grups." + groups_search: "Cerca nom o paraula clau" + groups_no_groups: "No s'ha trobat cap xarxa" + groups_about: "Sobre nosaltres" + groups_producers: "Les nostres productores" + groups_hubs: "Els nostres grups" + groups_contact_web: Contacte + groups_contact_social: Segueix + groups_contact_address: Adreça + groups_contact_email: Envieu-nos un correu electrònic + groups_contact_website: Visita el nostre lloc web + groups_contact_facebook: Segueix-nos a Facebook + groups_signup_title: Registra't com a grup + groups_signup_headline: Inscripció de xarxes + groups_signup_intro: "Som una plataforma sorprenent per a la comercialització col·laborativa, la forma més senzilla perquè els vostres membres i persones interessades arribin a nous mercats. Som sense ànim de lucre, assequibles i senzills." + groups_signup_email: Envieu-nos un correu electrònic + groups_signup_motivation1: Transformem els sistemes alimentaris de manera justa. + groups_signup_motivation2: És per això que sortim del llit cada dia. Som un globals i sense ànim de lucre, basats en codi font obert. Juguem just. Sempre podràs confiar en nosaltres. + groups_signup_motivation3: Sabem que tens grans idees i et volem ajudar. Compartirem els nostres coneixements, xarxes i recursos. Sabem que l'aïllament no genera canvis, així que ens associarem amb tu. + groups_signup_motivation4: Ens trobem on ets. + groups_signup_motivation5: És possible que formis part d'una xarxa de consumidores, grup de consum, productora o distribuïdora, un organisme industrial o un govern local. + groups_signup_motivation6: Independentment del teu paper en el moviment d'aliments local, estem preparades per ajudar-te. No obstant això, si vens a preguntar-se com és Open Food Network o que està fent a la teva part del món, comencem la conversa. + groups_signup_motivation7: Fem que els moviments dels aliments tinguin més sentit. + groups_signup_motivation8: Cal activar i habilitar les xarxes, oferim una plataforma de conversa i d'acció. Necessites un compromís real. Volem ajudar totes les jugadores, totes les parts interessades, tots els sectors. + groups_signup_motivation9: Necessites recursos. Et brindarem tota la nostra experiència. Necessites cooperació. Et connectarem millor amb una xarxa global d'iguals. + groups_signup_pricing: Compte de grup + groups_signup_studies: Casos d'estudi + groups_signup_contact: Preparada per debatre? + groups_signup_contact_text: "Posa't en contacte per descobrir què pot fer OFN per tu:" + groups_signup_detail: "Aquest és el detall." + login_invalid: "Correu electrònic o contrasenya no vàlids" + modal_hubs: "Grups" + modal_hubs_abstract: Els nostres grups són el punt de contacte entre tu i les persones que fan els teus aliments. + modal_hubs_content1: Pots cercar un grup convenient per ubicació o nom. Alguns grups tenen diversos punts on pots recollir les vostres comandes i alguns també proporcionen opcions de lliurament. Cada grup és un punt de venda amb operacions comercials i logística independents, per la qual cosa és normal que existeixin variacions d'un grup a un altre. + modal_hubs_content2: Només pots comprar en un grup de consum a la vegada. + modal_groups: "Xarxes / regions" + modal_groups_content1: Aquestes són les organitzacions i les relacions entre els grups que conformen l'OFN + modal_groups_content2: Alguns grups estan agrupats per localització o Ajuntament, altres per similituds no geogràfiques. + modal_how: "Com funciona" + modal_how_shop: Compreu a Open Food Network + modal_how_shop_explained: Cerca un grup de consum a prop teu per començar a comprar. Pots expandir cada grup per veure quins tipus de productes estan disponibles i fer clic per començar a comprar. (Només pots comprar en un grup alhora). + modal_how_pickup: Costes de recollida, lliurament i enviament + modal_how_pickup_explained: Algunes organitzacións o grups lliuren a la vostra porta, mentre que altres requereixen que aneu a buscar les vostres comandes. Podeu veure quines opcions hi ha disponibles a la pàgina d'inici i seleccionar el que vulgueu a les pàgines de confirmació de la compra. L'enviament costarà més, i els preus difereixen de l'organització. Cada grup és un punt de venda amb operacions comercials i logística independents, per la qual cosa es existeixen variacions entre grups. + modal_how_more: Aprèn-ne més + modal_how_more_explained: "Si vols saber-ne més sobre l'Open Food Network, com funciona i participar-hi, consulta:" + modal_producers: "Productors" + modal_producers_explained: "Les nostres productores elaboren tot el menjar deliciós que pots adquirir a l'Open Food Network." + producers_about: Sobre nosaltres + producers_buy: 'Compreu ' + producers_contact: Contacte + producers_contact_phone: Truca + producers_contact_social: Segueix + producers_buy_at_html: "Compra productes de %{enterprise} a:" + producers_filter: Filtra per + producers_filter_type: Tipus + producers_filter_property: Propietat + producers_title: Productors + producers_headline: Troba productores locals + producers_signup_title: Registra't com a productora + producers_signup_headline: Productores d'aliments, empoderades. + producers_signup_motivation: Ven el teu menjar i explica la teva història a diversos nous mercats. Estalvia temps i diners en totes les despeses generals. Done suport la innovació sense el risc. Hem aplanat el terreny de joc. + producers_signup_send: Uneix-te ara + producers_signup_enterprise: Comptes de l'organització + producers_signup_studies: Històries de les nostres productores. + producers_signup_cta_headline: Uneix-te ara! + producers_signup_cta_action: 'Uneix-te ara ' + producers_signup_detail: Aquest és el detall. + products_item: Article + products_description: Descripció + products_variant: Variant + products_quantity: Quantitat + products_available: Disponible? + products_producer: "Productor" + products_price: "Preu" + register_title: Registra't + sell_title: "Registra't" + sell_headline: "Afegiu-vos a Katuma!" + sell_motivation: "Mostra el teu bonic menjar." + sell_producers: "Productors" + sell_hubs: "Grups" + sell_groups: "Grups" + sell_producers_detail: "Configura un perfil per a la vostra empresa a Katuma en qüestió de minuts. En qualsevol moment pots convertir el teu perfil en una botiga en línia i vendre els teus productes directament a les consumidores." + sell_hubs_detail: "Configura un perfil per a la teva organització alimentària a Katuma. En qualsevol moment, pots modificar i convertir el teu perfil en una botiga de diverses productores." + sell_groups_detail: "Configura un directori personalitzat d'empreses (productores i altres empreses alimentàries) per a la teva regió o per a la teva xarxa, organització." + sell_user_guide: "Troba més informació a la nostra Guia d'usuari." + sell_embed: "També podem incrustar una botiga OFN al vostre web personalitzat o crear un lloc web personalitzat de la xarxa alimentària per a la teva regió." + sell_ask_services: "Pregunta'ns sobre els serveis d'OFN." + shops_title: Botigues + shops_headline: Compres, transformades. + shops_text: Els aliments creixen en cicles, les agricultores cullen en cicles, i nosaltres fem les comandes de menjar en cicles. Si trobes un cicle de comandes tancat, torna a consultar properament. + shops_signup_title: Inscriu-te com a grup + shops_signup_headline: Grups, il·limitats. + shops_signup_motivation: Sigui quin sigui el teu model, et donem suport. Tot i que canvieu, estarem amb tu. Som sense ànim de lucre, independents i obertes. Som les sòcies de programari que has somiat. + shops_signup_action: Uneix-te ara + shops_signup_pricing: Comptes de l'organització + shops_signup_stories: Històries dels nostres grups. + shops_signup_help: Estem preparadess per ajudar. + shops_signup_help_text: Necessites un millor retorn. Necessites noves compradores i sòcies logístiques. Necessites la teva història explicada a través de majoristes, minoristes i de la taula de la cuina. + shops_signup_detail: Aquest és el detall. + orders: Comandes + orders_fees: Comissions... + orders_edit_title: Cistella de la compra + orders_edit_headline: El teu cistell de la compra + orders_edit_time: Comanda preparada per + orders_edit_continue: Continuar comprant + orders_edit_checkout: Realitza la compra + orders_form_empty_cart: "Cistella buida" + orders_form_admin: Administració i manipulació + orders_form_total: Total + orders_oc_expired_headline: S'han tancat les comandes per a aquest cicle de comanda + orders_oc_expired_text: "Ho sentim, les comandes d'aquest cicle de comanda es van tancar fa %{time} ! Posa't en contacte amb el teu grup directament per veure si poden acceptar comandes fora de temps." + orders_oc_expired_text_others_html: "Ho sentim, les comandes d'aquest cicle de comanda es van tancar fa %{time} ! Posa't en contacte amb el teu grup directament per veure si poden acceptar comandes fora de temps %{link} ." + orders_oc_expired_text_link: "o consulta els altres cicles de comanda disponibles en aquest grup" + orders_oc_expired_email: "Correu electrònic:" + orders_oc_expired_phone: "Telèfon:" + orders_show_title: Confirmació de la comanda + orders_show_time: Comanda preparada + orders_show_order_number: "Comanda # %{number}" + orders_show_cancelled: Cancel·lada + orders_show_confirmed: Confirmada + orders_your_order_has_been_cancelled: "S'ha cancel·lat la teva comanda" + orders_could_not_cancel: "Disculpa, no s'ha pogut cancel·lar la teva comanda " + orders_cannot_remove_the_final_item: "No es pot eliminar l'article final d'una comanda, si us plau, en comptes d'això cancel·leu la comanda." + orders_bought_items_notice: + one: "Ja s'ha confirmat un element addicional per al cicle d'aquest ordre" + other: "%{count}articles addicionals confirmats per a aquest cicle de comandes" + orders_bought_edit_button: Edita articles confirmats + orders_bought_already_confirmed: "* ja confirmat" + orders_confirm_cancel: Estàs segur que vols cancel·lar aquesta comanda? + products_cart_distributor_choice: "Distribuïdora de la teva comanda:" + products_cart_distributor_change: "La teva distribuïdora d'aquesta comanda canviarà a %{name} si afegeixes aquest producte a la teva cistella." + products_cart_distributor_is: "La distribuïdora d'aquesta comanda és %{name}." + products_distributor_error: "Si us plau, completa la comanda a: %{link} abans de comprar amb una altra distribuïdora." + products_oc: "Cicle de comanda per a la teva comanda:" + products_oc_change: "El cicle de comandes d'aquesta comanda canviarà a %{name} si afegeixes aquest producte a la cistella." + products_oc_is: "El cicle de comandes per a aquesta comanda és %{name}." + products_oc_error: "Si us plau valideu la comanda de%{link} abans de comprar en un cicle de comanda diferent." + products_oc_current: "el vostre cicle de comanda actual" + products_max_quantity: Quantitat màxima + products_distributor: Distribuïdora + products_distributor_info: Quan seleccionis una distribuïdora per a la teva comanda, aquí es mostraran la seva d'adreça i hores de recollida. + products_distribution_adjustment_label: "Distribució del producte per part de %{distributor} per a %{product}" + shop_trial_expires_in: "El període de prova de la vostra botiga caduca en" + shop_trial_expired_notice: "Bones notícies! Hem decidit ampliar els període de prova de la botiga fins a un altre avís." + password: Contrasenya + remember_me: Recorda'm + are_you_sure: "Estàs segur?" + orders_open: Comandes obertes + closing: "Tancant" + going_back_to_home_page: "Tornant a la pàgina d'inici" + creating: Creant + updating: Actualitzant + failed_to_create_enterprise: "No s'ha pogut crear la vostra organització." + failed_to_create_enterprise_unknown: "No s'ha pogut crear la teva organització.\nAssegura't que tots els camps estan completament emplenats." + failed_to_update_enterprise_unknown: "No s'ha pogut actualitzar la teva organització.\nAssegura't que tots els camps estan completament emplenats." + enterprise_confirm_delete_message: "Això també eliminarà el %{product} que subministra aquesta organització. Estàs segura que vols continuar?" + order_not_saved_yet: "Encara no s'ha desat la teva comanda. Dona'ns uns segons per acabar!" + filter_by: "Filtra per" + hide_filters: "Amaga els filtres" + one_filter_applied: "S'ha aplicat 1 filtre" + x_filters_applied: " filtres aplicats" + submitting_order: "Enviant la teva comanda: espereu si us plau" + confirm_hub_change: "Estàs segura? Això canviarà el grup que has seleccionat i eliminarà els articles de la cistella de la compra." + confirm_oc_change: "N'estàs segur? Això canviarà el cicle de comanda seleccionat i eliminarà els articles de la cistella de la compra." + location_placeholder: "Escriu una ubicació..." + error_required: "no es pot deixar en blanc" + error_number: "ha de ser un nombre" + error_email: "ha de ser una adreça de correu electrònic" + error_not_found_in_database: "%{name} no s'ha trobat a la base de dades" + error_not_primary_producer: "%{name} no està habilitat com a productora" + error_no_permission_for_enterprise: "\"%{name}\": no tens permís per gestionar els productes d'aquesta organització" + january: "Gener" + february: "Febrer" + march: "Març" + april: "Abril" + may: "Maig" + june: "Juny" + july: "Juliol" + august: "Agost" + september: "Setembre" + october: "Octubre" + november: "Novembre" + december: "Desembre" + email_not_found: "No s'ha trobat l'adreça de correu electrònic " + email_unconfirmed: "Has de confirmar la teva adreça de correu electrònic abans de poder restablir la contrasenya." + email_required: "Has de proporcionar una adreça de correu electrònic" + logging_in: "Espereu-vos un moment, estem iniciant-vos la sessió" + signup_email: "El teu correu electrònic" + choose_password: "Escull una contrasenya" + confirm_password: "Confirma la contrassenya" + action_signup: "Registra't ara" + welcome_to_ofn: "Benvingut a la xarxa Open Food Network!" + have_an_account: "Ja tens un compte?" + action_login: "Inicia la sessió ara." + forgot_password: "Has oblidat la contrasenya?" + password_reset_sent: "S'ha enviat un correu electrònic amb instruccions per restablir la teva contrasenya." + reset_password: "Restablir la contrasenya" + who_is_managing_enterprise: "Qui és responsable de gestionar %{enterprise}?" + update_and_recalculate_fees: "Actualitza i recalcula les comissions" + registration: + steps: + images: + continue: "Continua" + back: "Enrere" + headline: "Gràcies!" + description: "Som-hi, pugem unes imatges perquè el teu perfil es vegi bonic. :)" + type: + headline: "El darrer pas per afegir %{enterprise}!" + question: "Ets productora?" + yes_producer: "Sí, sóc productora" + no_producer: "No, no sóc productora" + producer_field_error: "Si us plau, escull una de les opcions. Ets productora?" + yes_producer_help: "Les productores fan coses delicioses per menjar i/o beure. Ets productora si cultives, cries, elabores, cous, fermentes..." + no_producer_help: "Si no ets productora, probablement ets algú que ven i distribueix aliments. Pot ser un grup de consum, cooperativa, minorista, majorista o altres." + create_profile: "Crea un perfil" + enterprise: + registration: + modal: + steps: + details: + title: 'Detalls' + headline: "Comencem!" + enterprise: "Ep! Primer necessitem saber una mica sobre la vostra organització:" + producer: "Ep! Primer necessitem saber una mica sobre la teva granja:" + enterprise_name_field: "Nom de l'organització:" + producer_name_field: "Nom de la granja:" + producer_name_field_placeholder: "p. ex: Horta Josep Ribes" + producer_name_field_error: "Si us plau, selecciona un únic nom per a la teva organització" + address1_field: "Adreça línia 1:" + address1_field_placeholder: "p. ex: Carrer Ample, 123" + address1_field_error: "Si us plau, introdueix una adreça" + address2_field: "Adreça (línia 2):" + suburb_field: "Barri:" + suburb_field_placeholder: "p. ex: Les Corts" + suburb_field_error: "Si us plau, introdueix un barri" + postcode_field: "Codi postal:" + postcode_field_placeholder: "p. ex: 08870" + postcode_field_error: "El codi postal és obligatori" + state_field: "Estat:" + state_field_error: "Estat obligatori" + country_field: "País:" + country_field_error: "Si us plau, selecciona un país" + contact: + title: 'Contacte' + contact_field: 'Contacte principal' + contact_field_placeholder: 'Nom de contacte' + contact_field_required: "Has d'introduir un contacte principal." + email_field: 'Correu electrònic' + email_field_placeholder: 'ex: josep@hortajosepribes.com' + phone_field: 'Número de telèfon' + phone_field_placeholder: 'p. ex. 012 345 678' + type: + title: 'Tipus' + about: + title: 'Sobre' + images: + title: 'Imatges' + social: + title: 'Social' + enterprise_contact: "Contacte principal" + enterprise_contact_placeholder: "Nom de contacte" + enterprise_contact_required: "Has d'introduir un contacte principal." + enterprise_email_address: "Correu electrònic" + enterprise_email_placeholder: "ex: josep@hortajosepribes.com" + enterprise_phone: "Número de telèfon " + enterprise_phone_placeholder: "p. ex. 012 345 678 " + back: "Enrere" + continue: "Continua" + limit_reached_headline: "Oh, no!" + limit_reached_message: "Has arribat al límit!" + limit_reached_text: "Has arribat al límit de la quantitat de les organitzacions de les quals pots ser propietari a" + limit_reached_action: "Torna a la pàgina d'inici" + select_promo_image: "Pas 3. Selecciona una imatge promocional" + promo_image_tip: "Consell: es mostra com a banner, la mida recomanada és de 1200 × 260 píxels" + promo_image_label: "Escull una imatge promocional" + action_or: "O" + promo_image_drag: "Arrossegueu i deixeu anar la vostra imatge promocional aquí" + review_promo_image: "Pas 4. Reviseu el vostre banner promocional" + review_promo_image_tip: "Consell: per obtenir millors resultats, la teva imatge de promoció hauria d'omplir l'espai disponible" + promo_image_placeholder: "El teu logotip apareixerà aquí per a la seva revisió una vegada que s'hagi carregat" + uploading: "Carregant..." + select_logo: "Pas 1. Selecciona imatge del logotip" + logo_tip: "Consell: les imatges quadrades funcionaran millor, preferiblement com a mínim 300×300 px" + logo_label: "Escull una imatge de logotip" + logo_drag: "Arrossega i deixa anar el teu logotip aquí" + review_logo: "Pas 2. Revisa el teu logotip" + review_logo_tip: "Consell: per obtenir millors resultats, el teu logotip ha d'omplir l'espai disponible" + logo_placeholder: "El teu logotip apareixerà aquí per a la seva revisió una vegada que s'hagi carregat " + enterprise_about_headline: "Bona!" + enterprise_about_message: "Ara expliqueu-ne els detalls" + enterprise_success: "Enhorabona! %{enterprise} s'ha afegit a Open Food Network" + enterprise_registration_exit_message: "Si surts de l'assistent en qualsevol moment, podràs continuar creant el teu perfil anant a la interfície d'administració." + enterprise_description: "Descripció breu" + enterprise_description_placeholder: "Una frase breu que descrigui la teva organització" + enterprise_long_desc: "Descripció llarga" + enterprise_long_desc_placeholder: "Aquesta és la teva oportunitat per explicar la història de la vostra organització: què et fa diferent i meravellós? Et suggerim que la descripció sigui inferior a 600 caràcters o 150 paraules." + enterprise_long_desc_length: "%{num} caràcters / fins a 600 recomanats" + enterprise_abn: "NIF" + enterprise_abn_placeholder: "ex. 99 123 456 789" + enterprise_acn: "IVA" + enterprise_acn_placeholder: "p. ex. 123 456 789" + enterprise_tax_required: "Has de fer una selecció." + enterprise_final_step: "Pas final!" + enterprise_social_text: "Com poden la gent trobar %{enterprise}en línia?" + website: "Lloc web" + facebook: "Facebook" + facebook_placeholder: "p .ex: www.facebook.com/NomDeLaPàgina" + linkedin: "LinkedIn" + linkedin_placeholder: "p. ex: www.linkedin.com/YourNameHere" + twitter: "Twitter" + twitter_placeholder: "p. ex: @twitter_hortajosepribes" + instagram: "Instagram" + instagram_placeholder: "p. ex: @instagram_hortajosepribes" + registration_greeting: "Hola!" + registration_intro: "Ara pots crear un perfil per productora o grup de consum" + registration_action: "Comencem!" + registration_checklist: "Necessitaràs" + registration_time: "5-10 minuts" + registration_enterprise_address: "Adreça de l'organització" + registration_contact_details: "Dades de contacte principals" + registration_logo: "Imatge del logotip" + registration_promo_image: "Imatge promocional per al vostre perfil" + registration_about_us: "Text \"Sobre nosaltres\"" + registration_outcome_headline: "Què aconsegueixo?" + registration_outcome1_html: "El vostre perfil ajuda els usuaris a trobar i contactar a l'Open Food Network." + registration_outcome2: "Utilitza aquest espai per explicar la història de la teva organització, per ajudar a connectar-te amb la vostra presència social i a les xarxes." + registration_outcome3: "També és el primer pas cap al comenrç a través d'Open Food Network o per obrir una botiga en línia." + registration_finished_headline: "Acabat!" + registration_finished_thanks: "Gràcies per omplir els detalls de%{enterprise}." + registration_finished_login: "Pots canviar o actualitzar la teva organització en qualsevol moment accedint a Katuma i anant a Admin." + registration_contact_name: 'Nom de contacte' + registration_type_headline: "El darrer pas per afegir %{enterprise}!" + registration_type_question: "Ets productora?" + registration_type_producer: "Sí, sóc productora" + registration_type_no_producer: "No, no sóc productora" + registration_type_error: "Si us plau, escull una de les opcions. Ets productora?" + registration_type_producer_help: "Les productores fan coses delicioses per menjar i/o beure. Ets productora si cultives, cries, elabores, cous, fermentes..." + registration_type_no_producer_help: "Si no ets productora, probablement ets algú que ven i distribueix aliments. Pot ser un grup de consum, cooperativa, minorista, majorista o altres." + registration_detail_headline: "Comencem!" + registration_detail_enterprise: "Ep! Primer necessitem saber una mica sobre la vostra organització:" + registration_detail_producer: "Ep! Primer necessitem saber una mica sobre la teva granja:" + registration_detail_name_enterprise: "Nom de l'organització:" + registration_detail_name_producer: "Nom de la granja:" + registration_detail_name_placeholder: "p. ex: Horta Josep Ribes " + registration_detail_name_error: "Si us plau, selecciona un únic nom per a la teva organització" + registration_detail_address1: "Adreça línia 1: " + registration_detail_address1_placeholder: "p. ex: Carrer Ample, 123 " + registration_detail_address1_error: "Si us plau, introdueix una adreça" + registration_detail_address2: "Adreça (línia 2):" + registration_detail_suburb: "Barri: " + registration_detail_suburb_placeholder: "p. ex: Les Corts " + registration_detail_suburb_error: "Si us plau, introdueix un barri " + registration_detail_postcode: "Codi postal:" + registration_detail_postcode_placeholder: "p. ex: 08870" + registration_detail_postcode_error: "El codi postal és obligatori" + registration_detail_state: "Estat:" + registration_detail_state_error: "Estat obligatori" + registration_detail_country: "País:" + registration_detail_country_error: "Si us plau, selecciona un país" + shipping_method_destroy_error: "Aquest mètode d'enviament no es pot esborrar perquè s'hi fa referència en una comanda: %{number}." + accounts_and_billing_task_already_running_error: "Ja s'està executant una tasca, espera fins que hagi acabat" + accounts_and_billing_start_task_notice: "Tasca en cua" + fees: "Tarifes" + item_cost: "Cost de l'article" + shop_variant_quantity_min: "min" + shop_variant_quantity_max: "màx" + follow: "Segueix" + shop_for_products_html: "Compra productes de %{enterprise} a:" + shop_at: "Compra ara a:" + admin_fee: "Comissió d'administració" + sales_fee: "Comissió de venda" + packing_fee: "Comissió d'embalatge" + transport_fee: "Comissió de transport" + fundraising_fee: "Comissió d'autogestió" + price_graph: "Gràfic de preus" + included_tax: "Impostos incloses" + transaction: "Transacció" + transaction_date: "Data" + payment_state: "Estat del pagament" + shipping_state: "Estat d'enviament" + value: "Valor" + credit: "Crèdit" + Paid: "Pagat" + Ready: "Llest" + ok: D'acord + not_visible: no visible + you_have_no_orders_yet: "Encara no tens comandes" + admin_enterprise_relationships: "Permisos de l'organització" + admin_enterprise_relationships_everything: "Marcar tots" + admin_enterprise_relationships_permits: "Permet" + admin_enterprise_relationships_seach_placeholder: "Cerca" + admin_enterprise_relationships_button_create: "Crear" + admin_enterprise_groups: "Grups d'organització" + admin_enterprise_groups_name: "Nom" + admin_enterprise_groups_owner: "Propietària" + admin_enterprise_groups_enterprise: "Organitzacions" + admin_enterprise_groups_data_powertip: "La usuària principal responsable d'aquest grup." + admin_enterprise_groups_data_powertip_logo: "Això és el logotip del grup" + admin_enterprise_groups_data_powertip_promo_image: "Aquesta imatge es mostra a la part superior del perfil del grup" + admin_enterprise_groups_contact: "Contacte" + admin_enterprise_groups_contact_phone_placeholder: "p. ex.: 98 7654 3210" + admin_enterprise_groups_contact_address1_placeholder: "p. ex: Carrer Ample, 23" + admin_enterprise_groups_contact_city: "Barri" + admin_enterprise_groups_contact_city_placeholder: "p. ex: Sitges" + admin_enterprise_groups_contact_zipcode: "Codi postal" + admin_enterprise_groups_contact_zipcode_placeholder: "p. ex: 08870" + admin_enterprise_groups_contact_state_id: "Estat" + admin_enterprise_groups_contact_country_id: "País" + admin_enterprise_groups_web: "Recursos web" + admin_enterprise_groups_web_twitter: "p. ex: @horta_josepribes" + admin_enterprise_groups_web_website_placeholder: "p. ex.: www.hortajosepribes.coop" + admin_order_cycles: "Cicles de comanda de l'Admin" + open: "Obert" + close: "Tanca" + create: "Crear" + search: "Cerca" + supplier: "Proveïdora" + product_name: "Nom del producte" + product_description: "Descripció del producte" + coordinator: "Coordinador" + distributor: "Distribuïdora" + enterprise_fees: "Honoraris de l'organització" + process_my_order: "Processa la meva comanda" + delivery_instructions: Instruccions de lliurament + delivery_method: Mètode de lliurament + fee_type: "Tipus de tarifa" + tax_category: "Categoria d'impostos" + calculator: "Calculadora" + calculator_values: "Valors de la calculadora" + flat_percent_per_item: "Percentatge fixe (per article)" + flat_rate_per_item: "Tarifa fixa (per article)" + flat_rate_per_order: "Tarifa fixa (per comanda)" + flexible_rate: "Tarifa Flexible" + new_order_cycles: "Nou cicle de comanda" + new_order_cycle: "Nou cicle de comanda" + select_a_coordinator_for_your_order_cycle: "Selecciona una coordinadora per al vostre cicle de comanda" + notify_producers: 'Notifica les productores' + edit_order_cycle: "Edita el cicle de comanda" + roles: "Rols" + update: "Actualitzar" + delete: Suprimir + add_producer_property: "Afegeix propietats de la productora" + in_progress: "En progrés" + started_at: "Va començar a" + queued: "En cua" + scheduled_for: "Programat per a" + customers: "Consumidores" + please_select_hub: "Si us plau selecciona un grup" + loading_customers: "Carregant consumidores" + no_customers_found: "No s'han trobat consumidores" + go: "Anar" + hub: "Grup" + producer: "Productor" + product: "Producte" + price: "Preu" + on_hand: "Disponible" + save_changes: "Desa els canvis" + order_saved: "Comanda desada" + no_products: Sense productes + spree_admin_overview_enterprises_header: "Les meves organitzacions" + spree_admin_overview_enterprises_footer: "GESTIONAR LES MEVES ORGANITZACIONS" + spree_admin_enterprises_hubs_name: "Nom" + spree_admin_enterprises_create_new: "CREA'N UNA DE NOVA" + spree_admin_enterprises_shipping_methods: "Mètodes d'enviament" + spree_admin_enterprises_fees: "Honoraris de l'organització" + spree_admin_enterprises_none_create_a_new_enterprise: "CREA UNA NOVA ORGANITZACIÓ" + spree_admin_enterprises_none_text: "Encara no tens cap organització" + spree_admin_enterprises_tabs_hubs: "GRUPS" + spree_admin_enterprises_producers_manage_products: "GESTIONA ELS PRODUCTES" + spree_admin_enterprises_any_active_products_text: "No tens cap producte actiu." + spree_admin_enterprises_create_new_product: "CREA UN NOU PRODUCTE" + spree_admin_single_enterprise_alert_mail_confirmation: "Si us plau confirma l'adreça de correu electrònic de" + spree_admin_single_enterprise_alert_mail_sent: "Hem enviat un correu electrònic a" + spree_admin_overview_action_required: "Acció requerida" + spree_admin_overview_check_your_inbox: "Si us plat comproveu la vostra safata d'entrada per obtenir més instruccions. Gràcies!" + spree_admin_unit_value: Valor de la unitat + spree_admin_unit_description: Descripció de la unitat + spree_admin_supplier: Proveïdora + spree_admin_product_category: Categoria del producte + change_package: "Canvia el perfil" + spree_admin_single_enterprise_hint: "Suggeriment: per permetre que la gent us trobi, activeu la vostra visibilitat" + spree_admin_eg_pickup_from_school: "p. ex: 'Recollida al local del grup de consum'" + spree_admin_eg_collect_your_order: "p. ex: \"Recolliu la vostra comanda al c/Ample, n. 123'" + spree_order_availability_error: "La distribuïdora o el cicle de comanda no pot subministrar els productes de la vostra cistella" + spree_order_populator_error: "Aquesta distribuïdora o cicle de comanda no pot subministrar tots els productes de la vostra cistella. Si us plau trieu-ne d'altres." + spree_order_populator_availability_error: "Aquest producte no està disponible des de la distribuïdora o cicle de comanda seleccionat." + spree_distributors_error: "Cal seleccionar almenys un grup" + spree_user_enterprise_limit_error: "^ %{email} no està autoritzat a tenir més organitzacions (el límit és %{enterprise_limit})." + spree_variant_product_error: ha de tenir com a mínim una variant + on_ofn_map: "al mapa d'Open Food Network" + manage: "Gestiona" + resend: "Reenviar" + add_and_manage_products: "Afegeix & gestiona productes" + add_and_manage_order_cycles: "Afegeix & gestiona cicles de comanda" + manage_order_cycles: "Gestiona els cicles de comanda" + manage_products: "Gestiona els productes" + edit_profile_details: "Edita els detalls del perfil" + edit_profile_details_etc: "Canvia la descripció del perfil, les imatges, etc." + order_cycle: "Cicle de Comanda" + order_cycles: "Cicles de comanda" + remove_tax: "Suprimeix comissions" + enterprise_tos_message: "Volem treballar amb persones que comparteixen els nostres objectius i valors. Com a tal, demanem a les noves organitzacions que acceptin la nostra" + enterprise_tos_link_text: "Termes del servei." + enterprise_tos_agree: "Accepto els Termes i condicions anteriors" + admin_shared_address_1: "Adreça" + admin_shared_address_2: "Adreça (cont.)" + admin_share_city: "Municipi" + admin_share_zipcode: "Codi postal" + admin_share_country: "País" + admin_share_state: "Estat" + hub_sidebar_hubs: "Grups" + hub_sidebar_none_available: "Cap disponible" + hub_sidebar_manage: "Gestiona" + hub_sidebar_at_least: "Cal seleccionar almenys un grup " + hub_sidebar_blue: "blau" + hub_sidebar_red: "vermell" + shop_trial_in_progress: "El període de prova de la vostra botiga caduca en %{days}." + report_customers_distributor: "Distribuïdora" + report_customers_supplier: "Proveïdora" + report_customers_cycle: "Cicle de Comanda" + report_customers_type: "Tipus d'informe" + report_customers_csv: "Descarrega com a csv" + report_producers: "Productores:" + report_type: "Tipus d'informe:" + report_hubs: "Grups:" + report_payment: "Mètodes de pagament:" + report_distributor: "Distribuïdora:" + report_payment_by: 'Pagaments per tipus' + report_itemised_payment: 'Totals de pagament especificats' + report_payment_totals: 'Totals de pagament' + report_all: 'tot' + report_order_cycle: "Cicle de comanda:" + report_enterprises: "Organitzacions:" + report_users: "Usuàries:" + report_tax_rates: Taxes d'impostos + report_tax_types: Tipus d'impostos + report_header_order_cycle: Cicle de comanda + report_header_user: Usuàries + report_header_email: Correu electrònic + report_header_status: Estat + report_header_comments: Comentaris + report_header_first_name: Nom + report_header_last_name: Cognoms + report_header_phone: Telèfon + report_header_suburb: Barri + report_header_address: Adreça + report_header_billing_address: Adreça de facturació + report_header_relationship: Relació + report_header_hub: Grup + report_header_hub_address: Adreça del grup + report_header_to_hub: Per al grup + report_header_hub_code: Codi del grup + report_header_code: Codi + report_header_paid: Pagat? + report_header_delivery: Lliurament? + report_header_shipping: Enviament + report_header_shipping_method: Mètode d'enviament + report_header_shipping_instructions: Instruccions d'enviament + report_header_ship_street: Carrer d'enviament + report_header_ship_street_2: Carrer d'enviament 2 + report_header_ship_city: Ciutat d'enviament + report_header_ship_postcode: Codi postal d'enviament + report_header_ship_state: Estat d'enviament + report_header_billing_street: Carrer de facturació + report_header_billing_street_2: Carrer de facturació 2 + report_header_billing_street_3: Carrer de facturació 3 + report_header_billing_street_4: Carrer de facturació 4 + report_header_billing_city: Ciutat de facturació + report_header_billing_postcode: Codi postal de facturació + report_header_billing_state: Estat de facturació + report_header_incoming_transport: Transport entrant + report_header_special_instructions: Instruccions especials + report_header_order_number: Número de comanda + report_header_date: Data + report_header_tags: Etiquetes + report_header_items: Articles + report_header_items_total: "Total d'articles %{currency_symbol}" + report_header_delivery_charge: "Càrrec de lliurament (%{currency_symbol})" + report_header_tax_on_delivery: "Impost sobre el lliurament (%{currency_symbol})" + report_header_enterprise: Organització + report_header_customer: Consumidora + report_header_customer_code: Codi de la consumidora + report_header_product: Producte + report_header_product_properties: Propietats del producte + report_header_quantity: Quantitat + report_header_max_quantity: Quantitat màxima + report_header_variant: Variant + report_header_variant_value: Valor de la variant + report_header_variant_unit: Unitat de la variant + report_header_total_available: Total disponible + report_header_unallocated: Sense assignar + report_header_supplier: Proveïdora + report_header_producer: Productora + report_header_unit: Unitat + report_header_cost: Cost + report_header_shipping_cost: Despeses d'enviament + report_header_total_shipping_cost: Cost total d'enviament + report_header_payment_method: Mètode de pagament + report_header_sells: Ven + report_header_visible: Visible + report_header_price: Preu + report_header_distributor: Distribuïdora + report_header_distributor_address: Adreça de la distribuïdora + report_header_distributor_city: Ciutat de la distribuïdora + report_header_distributor_postcode: Codi postal de la distribuïdora + report_header_delivery_address: Adreça de lliurament + report_header_delivery_postcode: Codi postal de lliurament + report_header_weight: Pes + report_header_amount_owing: Import adeutat + report_header_amount_paid: Import pagat + report_header_units_required: Unitats necessàries + report_header_remainder: Restant + report_header_order_date: Data de comanda + report_header_order_id: Identificació de comanda + report_header_item_name: Nom de l'article + report_header_temp_controlled_items: Articles amb control de temperatura? + report_header_customer_name: Nom de la consumidora + report_header_customer_email: Correu electrònic de la consumidora + report_header_customer_phone: Telèfon de la consumidora + report_header_customer_city: Ciutat de la consumidora + report_header_payment_state: Estat del pagament + report_header_payment_type: Tipus de pagament + report_header_item_price: "Article (%{currency})" + report_header_total_price: "Total (%{currency})" + report_header_product_total_price: "Total del producte (%{currency})" + report_header_shipping_total_price: "Enviament total (%{currency})" + report_header_paypal_price: "PayPal (%{currency})" + report_header_sku: Número de referència (SKU) + report_header_amount: Import + report_header_total_excl_vat: "Total excl. impostos (%{currency_symbol})" + report_header_total_incl_vat: "Total incl. impostos (%{currency_symbol})" + report_header_temp_controlled: Control de temperatura? + report_header_is_producer: Productor? + initial_invoice_number: "Número de la comanda inicial:" + invoice_date: "Data del comprovant de compra:" + contains: "conté" + discount: "Descompte" + delete_product_variant: "L'última variant no es pot esborrar!" + progress: "progressió" + saving: "Desant..." + success: "èxit" + failure: "error" + unsaved_changes_confirmation: "Es perdran els canvis sense desar. Vols continuar de totes maneres?" + one_product_unsaved: "Els canvis d'un producte romanen sense desar." + products_unsaved: "Els canvis a %{n} productes romanen sense desar." + is_already_manager: "ja és gestor!" + no_change_to_save: " No hi ha cap canvi per desar" + user_invited: "%{email} ha estat convidada a gestionar aquesta organització" + add_manager: "Afegeix una usuària existent" + users: "Usuàries" + about: "Sobre" + images: "Imatges" + web: "Web" + primary_details: "Detalls principals" + adrdress: "Adreça" + contact: "Contacte" + social: "Social" + business_details: "Dades comercials" + properties: "Propietats" + shipping: "Enviament" + shipping_methods: "Mètodes d'enviament" + payment_methods: "Mètodes de Pagament" + payment_method_fee: "Tarifa de transacció" + inventory_settings: "Configuració de l'inventari" + tag_rules: "Regles d'etiqueta" + shop_preferences: "Preferències de la botiga" + enterprise_fee_whole_order: Comanda sencera + validation_msg_relationship_already_established: "^ Aquesta relació ja està establerta." + validation_msg_at_least_one_hub: "^ Cal seleccionar com a mínim un grup" + validation_msg_product_category_cant_be_blank: "^ La categoria de producte no pot estar en blanc" + validation_msg_is_associated_with_an_exising_customer: "Està associada amb una consumidora existent" + enterprise_name_error: "ja ha estat agafat. Si aquesta és la vostra organització i voleu reclamar-ne la propietat o si voleu comerciar amb aquesta organització, poseu-vos en contacte amb l'administradora actual d'aquest perfil %{email}." + enterprise_owner_error: "^ %{email} no està autoritzat a tenir més organitzacions (el límit és %{enterprise_limit})." + inventory_item_visibility_error: ha de ser veritable o falsa + product_importer_file_error: "error: no s'ha carregat cap fitxer" + product_importer_spreadsheet_error: "no s'ha pogut processar el fitxer: tipus de fitxer no vàlid" + product_importer_products_save_error: no s'han desat cap producte amb èxit + product_import_file_not_found_notice: 'No s''ha trobat el fitxer o no s''ha pogut obrir' + product_import_no_data_in_spreadsheet_notice: 'No s''ha trobat cap dada al full de càlcul' + order_choosing_hub_notice: El teu grup ha estat seleccionat. + order_cycle_selecting_notice: S'ha seleccionat el teu cicle de comanda. + enterprise_fees_update_notice: S'han actualitzat les comissions de l'organització. + enterprise_register_package_error: "Si us plau, selecciona un perfil" + enterprise_register_error: "No es pot completar el registre de %{enterprise}" + enterprise_register_success_notice: "Enhorabona! El registre de %{enterprise} s'ha completat!" + enterprise_bulk_update_success_notice: "Les organitzacions s'han actualitzat correctament" + enterprise_bulk_update_error: 'No s''ha pogut actualitzar' + order_cycles_create_notice: 'S''ha creat el cicle de comanda.' + order_cycles_update_notice: 'S''ha actualitzat el cicle de comanda.' + order_cycles_bulk_update_notice: 'S''han actualitzat els cicles de comanda.' + order_cycles_clone_notice: "S'ha clonat el teu cicle de comanda %{name}." + order_cycles_email_to_producers_notice: 'Els correus electrònics per enviar a les productores estan en cua.' + order_cycles_no_permission_to_coordinate_error: "Cap de les vostres organitzacions té permís per coordinar un cicle de comanda" + order_cycles_no_permission_to_create_error: "No teniu permís per crear un cicle de comandes coordinat per aquesta organització" + back_to_orders_list: "Torna a la llista de comandes" + no_orders_found: "No s'han trobat comandes" + order_information: "Informació de la comanda" + date_completed: "Data finalitzada" + amount: "Import" + state_names: + ready: Llest + pending: Pendents + shipped: Enviat + js: + saving: 'Desant...' + changes_saved: 'S''han desat els canvis.' + save_changes_first: Desa els canvis en primer lloc. + all_changes_saved: S'han desat tots els canvis + unsaved_changes: Teniu canvis sense desar + all_changes_saved_successfully: Tots els canvis s'han desat correctament + oh_no: "Ah no! No he pogut desar els canvis." + unauthorized: "No tens autorització per accedir a aquesta pàgina." + error: Error + unavailable: No disponible + profile: Perfil + hub: Grup + shop: Botiga + choose: Escull + resolve_errors: Si us plau, resol els errors següents + more_items: "+ %{count} Més" + admin: + enterprise_limit_reached: "Has assolit el límit estàndard d'organitzacions per compte. Escriu a %{contact_email} si necessites augmentar-lo." + modals: + got_it: Ho tinc + close: "Tanca" + invite: "Convida" + invite_title: "Convida un usuari no registrat" + tag_rule_help: + title: Regles d'etiqueta + overview: Visió general + overview_text: > + Les regles d'etiquetes proporcionen una manera de descriure quins elements + són visibles o pel contrari s'amaguen a segons quins clients. Els elements + poden ser mètodes d'enviament, mètodes de pagament, productes i cicles + de comanda. + by_default_rules: "Regles 'per defecte...'" + by_default_rules_text: > + Les regles predeterminades o per defecte permeten ocultar elements perquè + no siguin visibles de manera predeterminada. Aquest comportament es + pot reemplaçar per regles no predeterminades per a clients amb etiquetes + particulars. + customer_tagged_rules: "Regles de 'clients etiquetats...'" + customer_tagged_rules_text: > + En crear regles relacionades amb una etiqueta de client específica, + podeu anul·lar el comportament predeterminat (ja sigui per mostrar o + ocultar elements) per als clients amb l'etiqueta especificada. + panels: + save: DESA + saved: DESAT + saving: DESANT + enterprise_package: + hub_profile: Perfil de grup + hub_profile_cost: "COST: SEMPRE GRATUÏT" + hub_profile_text1: > + La gent podrà trobar-vos i posar-se en contacte amb vosaltres a Open + Food Network. La vostra organització serà visible al mapa i es podrà + cercar a les llistes. + hub_profile_text2: > + Tenir un perfil i fer contactes amb vostre moviment alimentari local + a través d'Open Food Network sempre serà gratuït. + hub_shop: Botiga + hub_shop_text1: > + La teva organització és la columna vertebral del vostre sistema alimentari + local. Afegeix productes d'altres organitzacions productores i ven-los + a través de la vostra botiga a la Katuma. + hub_shop_text2: > + Els grups poden prendre moltes formes, ja siguin una cooperativa d'aliments, + un grup de compra, o una botiga de queviures local, un supermercat cooperatiu. + hub_shop_text3: > + Si també vols vendre els teus propis productes, hauràs de canviar d'organització + per ser productora. + choose_package: Si us plau tria un perfil + choose_package_text1: > + La teva organització no s'activarà completament fins que es seleccioni + un perfil d'entre les opcions de l'esquerra. + choose_package_text2: > + Fes clic a una opció per veure informació més detallada sobre cada perfil + i prem el botó vermell "DESA" quan hagis acabat. + profile_only: Només perfil + profile_only_cost: "COST: SEMPRE GRATUÏT" + profile_only_text1: > + Un perfil et fa visible i contactablede cara als altres i és una forma + de compartir la teva història. + profile_only_text2: > + Si prefereixes concentrar-te en produir menjar i vols deixar la feina + de vendre'l a una altra persona, no necessitaràs una botiga Katuma. + profile_only_text3: > + Afegeix els teus productes a Katuma, permetent que els grups de consum + o altres puguin emmagatzemar-los a les seves botigues. + producer_shop: Productora amb botiga + producer_shop_text1: > + Ven els teus productes directament a les consumidores a través de la + teva pròpia botiga a Katuma. + producer_shop_text2: > + Una botiga de productores només és per als teus productes, si vols vendre + productes produïts / cultivats per un altre, selecciona "Grup de productores" + producer_hub: Grup de productores + producer_hub_text1: > + La teva organització és la columna vertebral del vostre sistema alimentari + local. Pots vendre els vostres propis productes i productes agregats + d'altres organitzacions a través de la vostra botiga a Katuma. + producer_hub_text2: > + Un grup pot prendre moltes formes, ja siguin una cooperativa de consum, + un programa de cistelles o un hort comunitari que produeix per als seus + socis. + producer_hub_text3: > + L'Open Food Network té com a objectiu donar suport a tots els models + de grups possibles, de manera que, independentment de la teva situació, + volem proporcionar les eines que necessites per executar la teva organització + o empresa alimentària local. + get_listing: Obtenir una llista + always_free: SEMPRE GRATUÏT + sell_produce_others: Ven productes d'altres + sell_own_produce: Ven els teus propis productes + sell_both: Ven productes d'un mateix i d'altres + enterprise_producer: + producer: Productor + producer_text1: > + Les productores fan coses delicioses per menjar o beure. Ets productora + si cultives, cries, fermentes, cous pa, vens llet, fas formatges... + producer_text2: > + Les productores també poden realitzar altres funcions, com ara agregar + productes d'altres productores i vendre-la a través d'una botiga a Katuma. + non_producer: No productora + non_producer_text1: > + Les no-productores no produeixen cap aliment, per la qual cosa no poden + crear productes propis per vendre a través de Katuma. + non_producer_text2: > + En canvi, les no-productores s'especialitzen en vincular les productores + amb el consumidor final, ja sigui classificant, envasant, venent o lliurant + aliments. + producer_desc: Productores d'aliments + producer_example: 'p. ex: AGRICULTORES, FORNERES, CERVESERES, TRANSFORMADORES D''ALIMENTS...' + non_producer_desc: Totes les altres organitzacions alimentàries + non_producer_example: 'p. ex: botigues de queviures, cooperatives d''aliments, grups de compra...' + enterprise_status: + description: Descripció + resolve: Resoldre + new_tag_rule_dialog: + select_rule_type: "Selecciona un tipus de regla:" + orders: + index: + per_page: "%{results} per pàgina" + resend_user_email_confirmation: + resend: "Reenviar" + sending: "Reenviar..." + done: "Reenviament fet ✓" + failed: "Reenviament fallit ✗" + out_of_stock: + reduced_stock_available: Estoc reduït disponible + out_of_stock_text: > + Mentre heu estat comprant, s'han reduït els nivells d'existències d'un o + més dels productes de la cistella. Aquí podeu veure el que ha canviat: + now_out_of_stock: ara està fora d'estoc. + only_n_remainging: "ara només n'hi ha %{num}restants." + variants: + on_demand: + 'yes': "Sota demanda" + variant_overrides: + on_demand: + use_producer_settings: "Utilitzeu la configuració d'inventari de la productora" + 'yes': "Sí" + 'no': "No" + inventory_products: "Productes de l'inventari" + hidden_products: "Productes ocults" + new_products: "Nous productes" + reset_stock_levels: Restablir els nivells d'existències a valors predeterminats + remain_unsaved: romanen sense desar. + no_changes_to_save: No hi ha canvis per desar. + no_authorisation: "No he pogut obtenir l'autorització per guardar aquests canvis, de manera que romanen sense desar." + some_trouble: "He tingut problemes per desar: %{errors}" + changing_on_hand_stock: Canviant els nivells de disponibilitat d'existències... + stock_reset: Existències restablertes als valors predeterminats. + tag_rules: + show_hide_variants: 'Mostra o amaga variants a la meva botiga' + show_hide_shipping: 'Mostra o amaga mètodes d''enviament en la validació de la comanda' + show_hide_payment: 'Mostra o amaga mètodes de pagament en la validació de la comanda' + show_hide_order_cycles: 'Mostra o amaga els cicles de comanda a la meva botiga' + visible: VISIBLE + not_visible: NO VISIBLE + services: + unsaved_changes_message: Actualment hi ha canvis sense desar, vols desar-los o ignorar-los? + save: DESA + ignore: IGNORA + add_to_order_cycle: "afegeix al cicle de comanda" + manage_products: "gestiona els productes" + edit_profile: "edita el perfil" + add_products_to_inventory: "afegeix productes a l'inventari" + resources: + could_not_delete_customer: 'No s''ha pogut eliminar la consumidora' + order_cycles: + create_failure: "No s'ha pogut crear el cicle de comanda" + update_success: 'S''ha actualitzat el cicle de comanda.' + update_failure: "No s'ha pogut actualitzar el cicle de comanda" + no_distributors: 'No hi ha distribuïdores en aquest cicle de comanda. Aquest cicle de comanda no serà visible per a les consumidores fins que no n''afegiu un. Voleu continuar desant aquest cicle de comanda? ' + enterprises: + producer: "Productora" + non_producer: "No-productora" + customers: + select_shop: 'Si us plau seleccioneu primer una botiga' + could_not_create: Ho sentim! No s'ha pogut crear + subscriptions: + closes: tanca + closed: tancat + close_date_not_set: La data de tancament no està establerta + producers: + signup: + start_free_profile: "Comença amb un perfil gratuït i amplia'l quan estiguis preparada." + spree: + email: Correu electrònic + account_updated: "Compte actualitzat!" + my_account: "El meu compte" + date: "Data" + time: "Hora" + layouts: + admin: + header: + store: Botiga + admin: + orders: + index: + new_order: "Nova comanda" + capture: "Captura" + ship: "Enviament" + edit: "Editar" + note: "Nota" + first: "Primer" + last: "Últim" + next: "Següent" + loading: "S'està carregant" + no_orders_found: "No s'ha trobat cap comanda" + results_found: "%{number} Resultats trobats." + viewing: "Veient %{start} a %{end}." + invoice: + issued_on: Publicat a + tax_invoice: FACTURA D'IMPOSTOS + code: Codi + from: De + to: Per a + form: + distribution_fields: + title: Distribució + distributor: "Distribuïdora:" + order_cycle: "Cicle de comanda:" + overview: + order_cycles: + order_cycles: "Cicles de comanda" + order_cycles_tip: "Els cicles de comanda determinen quan i on els teus productes estan disponibles per a les consumidores." + you_have_active: + zero: "No tens cicles de comanda actius." + one: "Tens un cicle de comanda actiu." + other: "Tens %{count}cicles de comanda actius" + manage_order_cycles: "GESTIONA ELS CICLES DE COMANDA" + payment_methods: + new: + new_payment_method: "Nou mètode de pagament" + back_to_payment_methods_list: "Tornar a la llista de mètodes de pagament" + edit: + editing_payment_method: "Edició del mètode de pagament" + back_to_payment_methods_list: "Tornar a la llista de mètodes de pagament" + stripe_connect: + enterprise_select_placeholder: Tria ... + loading_account_information_msg: S'està carregant la informació del compte de Stripe, si us plau espera... + stripe_disabled_msg: Els pagaments de Stripe han estat inhabilitat per l'administrador del sistema. + request_failed_msg: Ho sentim. S'ha produït un error en provar de verificar els detalls del compte amb Stripe... + account_missing_msg: No hi ha cap compte de Stripe per a aquesta organització. + access_revoked_msg: S'ha revocat l'accés a aquest compte de Stripe, si us plau torna a connectar el teu compte. + status: Estat + connected: Connectat + account_id: Identificador del compte + business_name: Nom de l'empresa + charges_enabled: Càrrecs habilitats + payments: + source_forms: + stripe: + error_saving_payment: Error en desar el pagament + submitting_payment: S'està lliurant el pagament... + products: + new: + title: 'Nou producte' + index: + header: + title: Edició de productes en bloc + indicators: + title: CARREGANT PRODUCTES + no_products: "Encara no hi ha productes. Per què no afegeixes alguna cosa?" + no_results: "Ho sentim, no hi ha coincidència de resultats" + products_head: + name: Nom + unit: Unitat + display_as: Mostra com + category: Categoria + tax_category: Categoria d'impostos + inherits_properties?: Hereda propietats? + available_on: Disponible el + products_variant: + new_variant: "Nova variant" + product_name: Nom del producte + primary_taxon_form: + product_category: Categoria del producte + group_buy_form: + group_buy: "Compra en grup?" + display_as: + display_as: Mostra com + users: + index: + listing_users: "Llistat d'usuàries" + new_user: "Nova usuària" + user: "Usuàries" + enterprise_limit: "Límit d'organitzacions" + search: "Cerca" + email: "E-mail" + edit: + editing_user: "S'està editant una usuària" + back_to_users_list: "Torna a la llista d'usuàries" + general_settings: "Configuració general" + form: + email: "E-mail" + roles: "Rols" + enterprise_limit: "Límit d'organitzacions" + confirm_password: "Confirma la contrassenya" + password: "Contrasenya" + email_confirmation: + confirmation_pending: "La confirmació de correu electrònic està pendent. Hem enviat un correu electrònic de confirmació a %{address}." + variants: + autocomplete: + producer_name: Productor + general_settings: + edit: + cookies_consent_banner_toggle: "Mostra banner de consentiment de cookies" + privacy_policy_url: "URL de la política de privacitat " + cookies_policy_matomo_section: "Mostra la secció de Matomo a la pàgina de política de cookies" + cookies_policy_ga_section: "Mostra la secció de Google Analytics a la pàgina de política de cookies" + checkout: + payment: + stripe: + choose_one: Escull-ne un + enter_new_card: Introdueix els detalls d'una targeta nova + used_saved_card: "Utilitza una targeta desada:" + or_enter_new_card: "O bé introdueix els detalls d'una nova targeta:" + remember_this_card: Recordar aquesta targeta? + inventory: Inventari + orders: + edit: + login_to_view_order: "Si us plau inicia sessió per veure la teva comanda." + bought: + item: "Ja està demanat en aquest cicle de comandes" + order_mailer: + invoice_email: + hi: "Hola %{name}" + invoice_attached_text: Trobareu adjunta un comprovant de la compra per a la vostra comanda recent + order_state: + address: adreça + adjustments: ajustaments + awaiting_return: esperant el retorn + canceled: cancel·lat + cart: cistella + complete: completa + confirm: confirma + delivery: lliurament + paused: en pausa + payment: pagament + pending: pendents + resumed: reprès + returned: retornat + skrill: habilitat + subscription_state: + active: actiu + pending: pendents + ended: acabat + paused: en pausa + canceled: cancel·lat + payment_states: + completed: completat + credit_owed: crèdit a deure + failed: error + paid: pagat + pending: pendents + processing: processant + void: buit + invalid: invàlid + shipment_states: + backorder: per tornar + partial: parcial + pending: pendents + ready: llest + shipped: enviat + user_mailer: + reset_password_instructions: + request_sent_text: | + S'ha fet una sol·licitud per restablir la teva contrasenya. + Si no has fet aquesta sol·licitud, simplement ignora aquest correu electrònic. + link_text: > + Si has fet aquesta sol·licitud, fes clic a l'enllaç següent: + issue_text: | + Si l'URL anterior no funciona, prova de copiar-lo i enganxar-lo al navegador. + Si continues tenint problemes, no dubtis en contactar-nos. + confirmation_instructions: + subject: Si us plau confirma el teu compte d'OFN + weight: Pes (per kg) + zipcode: Codi postal + users: + form: + account_settings: Configuració del compte + show: + tabs: + orders: Comandes + cards: Targetes de crèdit + transactions: Transaccions + settings: Configuració del compte + unconfirmed_email: "Confirmació pendent de correu electrònic per a: %{unconfirmed_email}. La vostra adreça de correu electrònic s'actualitzarà un cop confirmat el nou correu electrònic." + orders: + open_orders: Comandes obertes + past_orders: Comandes anteriors + transactions: + transaction_history: Historial de transaccions + open_orders: + order: Comanda + shop: Botiga + changes_allowed_until: Canvis permesos fins a + items: Articles + total: Total + edit: Editar + cancel: Cancel·lar + closed: Tancat + until: Fins a + past_orders: + order: Comanda + shop: Botiga + completed_at: Completat a + items: Articles + total: Total + paid?: Pagat? + view: Veure + saved_cards: + default?: Per defecte? + delete?: Suprimeix? + cards: + authorised_shops: Botigues autoritzades + authorised_shops_popover: Aquesta és la llista de botigues que tenen permís per carregar la teva targeta de crèdit predeterminada per a qualsevol subscripció (és a dir, comandes de repetició) que puguis tenir. Les dades de la targeta es mantindran segures i no es compartiran amb els propietaris de botigues. Sempre se us notificarà quan se us faci un càrrec. + saved_cards_popover: Aquesta és la llista de targetes que heu optat per guardar per a un ús posterior. El vostre "valor predeterminat" es seleccionarà automàticament quan valideu una comanda i es pot carregar per qualsevol botiga a la que li hagueu permès fer-ho (vegeu a la dreta). + authorised_shops: + shop_name: "Nom de la botiga" + allow_charges?: "Permetre càrrecs?" + localized_number: + invalid_format: té un format no vàlid. Si us plau introdueix un número. diff --git a/config/locales/en.yml b/config/locales/en.yml index eb25e3dbe7..3c4b35ebbd 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -54,6 +54,16 @@ en: on_demand_but_count_on_hand_set: "must be blank if on demand" limited_stock_but_no_count_on_hand: "must be specified because forcing limited stock" activemodel: + attributes: + order_management/reports/enterprise_fee_summary/parameters: + start_at: "Start" + end_at: "End" + distributor_ids: "Hubs" + producer_ids: "Producers" + order_cycle_ids: "Order Cycles" + enterprise_fee_ids: "Fees Names" + shipping_method_ids: "Shipping Methods" + payment_method_ids: "Payment Methods" errors: models: subscription_validator: @@ -100,6 +110,14 @@ en: order_cycle: cloned_order_cycle_name: "COPY OF %{order_cycle}" + validators: + date_time_string_validator: + not_string_error: "must be a string" + invalid_format_error: "must be valid" + integer_array_validator: + not_array_error: "must be an array" + invalid_element_error: "must contain only valid integers" + enterprise_mailer: confirmation_instructions: subject: "Please confirm the email address for %{enterprise}" @@ -262,6 +280,7 @@ en: actions: create_and_add_another: "Create and Add Another" + create: "Create" admin: # Common properties / models begins_at: Begins At @@ -1055,7 +1074,9 @@ en: description: Invoices for import into Xero packing: name: Packing Reports - + enterprise_fee_summary: + name: "Enterprise Fee Summary" + description: "Summary of Enterprise Fees collected" subscriptions: subscriptions: Subscriptions new: New Subscription @@ -1089,6 +1110,7 @@ en: this_is_an_estimate: | The displayed prices are only an estimate and calculated at the time the subscription is changed. If you change prices or fees, orders will be updated, but the subscription will still display the old values. + not_in_open_and_upcoming_order_cycles_warning: "There are no open or upcoming order cycles for this product." details: details: Details invalid_error: Oops! Please fill in all of the required fields... @@ -1103,6 +1125,7 @@ en: details: Details address: Address products: Products + no_open_or_upcoming_order_cycle: "No Upcoming Order Cycle" product_already_in_order: This product has already been added to the order. Please edit the quantity directly. orders: number: Number @@ -1348,7 +1371,6 @@ en: ie_warning_firefox: Download Firefox ie_warning_ie: Upgrade Internet Explorer ie_warning_other: "Can't upgrade your browser? Try Open Food Network on your smartphone :-)" - legal: cookies_policy: header: "How We Use Cookies" @@ -2156,7 +2178,6 @@ See the %{link} to find out more about %{sitename}'s features and to start using spree_admin_enterprises_none_text: "You don't have any enterprises yet" spree_admin_enterprises_tabs_hubs: "HUBS" spree_admin_enterprises_producers_manage_products: "MANAGE PRODUCTS" - spree_admin_enterprises_any_active_products_text: "You don't have any active products." spree_admin_enterprises_create_new_product: "CREATE A NEW PRODUCT" spree_admin_single_enterprise_alert_mail_confirmation: "Please confirm the email address for" spree_admin_single_enterprise_alert_mail_sent: "We've sent an email to" @@ -2684,6 +2705,44 @@ See the %{link} to find out more about %{sitename}'s features and to start using signup: start_free_profile: "Start with a free profile, and expand when you're ready!" + order_management: + reports: + enterprise_fee_summary: + date_end_before_start_error: "must be after start" + parameter_not_allowed_error: "You are not authorized to use one or more selected filters for this report." + fee_calculated_on_transfer_through_all: "All" + fee_type: + payment_method: "Payment Transaction" + shipping_method: "Shipment" + fee_placements: + supplier: "Incoming" + distributor: "Outgoing" + coordinator: "Coordinator" + tax_category_name: + shipping_instance_rate: "Platform Rate" + formats: + csv: + header: + fee_type: "Fee Type" + enterprise_name: "Enterprise Owner" + fee_name: "Fee Name" + customer_name: "Customer" + fee_placement: "Fee Placement" + fee_calculated_on_transfer_through_name: "Fee Calc on Transfer Through" + tax_category_name: "Tax Category" + total_amount: "$$ SUM" + html: + header: + fee_type: "Fee Type" + enterprise_name: "Enterprise Owner" + fee_name: "Fee Name" + customer_name: "Customer" + fee_placement: "Fee Placement" + fee_calculated_on_transfer_through_name: "Fee Calc on Transfer Through" + tax_category_name: "Tax Category" + total_amount: "$$ SUM" + invalid_filter_parameters: "The filters you selected for this report are invalid." + spree: # TODO: remove `email` key once we get to Spree 2.0 email: Email @@ -2752,6 +2811,11 @@ See the %{link} to find out more about %{sitename}'s features and to start using distributor: "Distributor:" order_cycle: "Order cycle:" overview: + products: + active_products: + zero: "You don't have any active products." + one: "You have one active product" + other: "You have %{count} active products" order_cycles: order_cycles: "Order Cycles" order_cycles_tip: "Order cycles determine when and where your products are available to customers." @@ -2825,6 +2889,14 @@ See the %{link} to find out more about %{sitename}'s features and to start using bulk_coop_allocation: 'Bulk Co-op - Allocation' bulk_coop_packing_sheets: 'Bulk Co-op - Packing Sheets' bulk_coop_customer_payments: 'Bulk Co-op - Customer Payments' + enterprise_fee_summaries: + filters: + date_range: "Date Range" + report_format_csv: "Download as CSV" + generate_report: "Generate Report" + report: + none: "None" + select_and_search: "Select filters and click on GENERATE REPORT to access your data." users: index: listing_users: "Listing Users" diff --git a/config/locales/fr_BE.yml b/config/locales/fr_BE.yml index c5cfc0054f..94291967f1 100644 --- a/config/locales/fr_BE.yml +++ b/config/locales/fr_BE.yml @@ -1105,7 +1105,7 @@ fr_BE: footer_legal_call: "Lire nos" footer_legal_tos: "Termes et conditions" footer_legal_visit: "Nous trouver sur" - footer_legal_text_html: "Open Food Network est une plateforme logicielle open source, libre et gratuite. Nos données sont protégées sous licence %{content_license} et notre code sous %{code_license}." + footer_legal_text_html: "Open Food Network est une plateforme logicielle open source et libre. Nos données sont protégées sous licence %{content_license} et notre code sous %{code_license}." footer_data_text_with_privacy_policy_html: "Nous prenons soin de vos données. Voir notre %{privacy_policy} et %{cookies_policy}." footer_data_text_without_privacy_policy_html: "Nous prenons soin de vos données. Voir notre %{cookies_policy}." footer_data_privacy_policy: "politique de confidentialité" diff --git a/config/locales/it.yml b/config/locales/it.yml index ddb3185edc..5a123f6480 100644 --- a/config/locales/it.yml +++ b/config/locales/it.yml @@ -602,6 +602,7 @@ it: desc_long_placeholder: Racconta di te ai consumatori. Questa informazione comparirà nel tuo profilo pubblico. business_details: abn: ABN + abn_placeholder: es. 99 123 456 789 acn: ACN acn_placeholder: es. 123 456 789 display_invoice_logo: Mostra logo nelle fatture @@ -1131,6 +1132,7 @@ it: total_excl_tax: "Totale (Tasse escl.):" total_incl_tax: "Totale (Tasse incl.):" abn: "CF:" + acn: "ACN:" invoice_issued_on: "Fattura emessa il" order_number: "Numero fattura:" date_of_transaction: "Data della transazione:" @@ -1149,7 +1151,9 @@ it: menu_5_title: "About" menu_5_url: "http://www.openfoodnetwork.org/" menu_6_title: "Connetti" + menu_6_url: "https://openfoodnetwork.org/au/connect/" menu_7_title: "Impara" + menu_7_url: "https://openfoodnetwork.org/au/learn/" logo: "Logo (640x130)" logo_mobile: "Mobile logo (75x26)" logo_mobile_svg: "Mobile logo (SVG)" @@ -2216,6 +2220,9 @@ it: validation_msg_tax: "^Categoria d'imposta richiesta" validation_msg_tax_category_cant_be_blank: "^Categoria di tassa non può essere vuoto" validation_msg_is_associated_with_an_exising_customer: "è associato con un cliente esistente" + content_configuration_pricing_table: "(TODO: tabella dei prezzi)" + content_configuration_case_studies: "(TODO: Casi studio)" + content_configuration_detail: "(TODO: Dettaglio)" enterprise_name_error: "è già stato utilizzato. Se questo è il nome della tua azienda e vorresti reclamarne la proprietà, o se vuoi contattare questa azienda, puoi contattare l'attuale referente di questo profilo a %{email}." enterprise_owner_error: "^%{email}non può gestire altre aziende (il limite è %{enterprise_limit})." enterprise_role_uniqueness_error: "^Questo ruolo è già presente." @@ -2253,6 +2260,7 @@ it: back_to_orders_list: "Indietro alla lista delle gentili richieste" no_orders_found: "Nessuna gentile richiesta trovata" order_information: "Informazioni Gentile Richiesta" + date_completed: "Data di completamento" amount: "Quantità" state_names: ready: Pronto @@ -2275,6 +2283,7 @@ it: choose: Scegli resolve_errors: 'Per favore risolvi i seguenti errori:' more_items: "+ %{count} ancora" + default_card_updated: Carta predefinita aggiornata admin: enterprise_limit_reached: "Hai raggiunto il limite standard di aziende per account. Scrivi a %{contact_email} se hai bisogno di aumentarlo." modals: @@ -2368,14 +2377,18 @@ it: non_producer_example: es. Botteghe, Food Coop, GAS enterprise_status: status_title: "%{name} è impostato e pronto a partire!" + severity: Gravità description: Descrizione resolve: Risolvi new_tag_rule_dialog: select_rule_type: "Seleziona un tipo di regola:" orders: index: + per_page: "%{results} per pagina" view_file: Vedi file compiling_invoices: Compilazione fatture + bulk_invoice_created: Fattura all'ingrosso creata + bulk_invoice_failed: Creazione fattura all'ingrosso fallita please_wait: Si prega di attendere che il PDF sia pronto prima di chiudere questo modale resend_user_email_confirmation: resend: "Invia nuovamente" @@ -2394,6 +2407,7 @@ it: 'yes': "A richiesta" variant_overrides: on_demand: + use_producer_settings: "Usa le impostazioni dello stock del produttore" 'yes': "Sì" 'no': "No" inventory_products: "Inventario Prodotti" @@ -2401,6 +2415,9 @@ it: new_products: "Nuovi Prodotti" reset_stock_levels: Resetta le quantità disponibili alla quantità predefinita changes_to: Cambia in + one_override: una si è sovrascritta + overrides: sovrascrive + remain_unsaved: restano da memorizzare no_changes_to_save: Nessuna modifica da salvare. no_authorisation: "Non abbiamo l'autorizzazione per salvare queste modifiche. " some_trouble: "Abbiamo avuto problemi durante il salvataggio: %{errors}" @@ -2449,16 +2466,23 @@ it: admin: orders: index: + listing_orders: "Listino Ordini" new_order: "Nuovo ordine" + capture: "Cattura" ship: "Spedizione" edit: "Modifica" + note: "Nota" + first: "Primo" + last: "Ultimo" previous: "Precedente" next: "Prossimo" loading: "Caricamento" no_orders_found: "Nessuna gentile richiesta trovata" results_found: "%{number} Risultati trovati." + viewing: "Guardando da %{start} a %{end}" print_invoices: "Stampa fatture" invoice: + issued_on: Emesso il tax_invoice: FATTURA DELLE TASSE code: Codice from: Da @@ -2490,12 +2514,18 @@ it: stripe_disabled_msg: I pagamenti Stripe sono stati disabilitati dall'amministratore di sistema. request_failed_msg: Spiacenti, qualcosa è andato storto mentre cercavamo di verificare i dettagli dell'account con Stripe... account_missing_msg: Non esiste un account Stripe per questa azienda. + connect_one: Connetti One access_revoked_msg: L'accesso a questo account Stripe è stato revocato, per favore ricollega il tuo account. status: Stato connected: Connesso account_id: Account ID business_name: Ragione sociale charges_enabled: Cambi Consentiti + payments: + source_forms: + stripe: + error_saving_payment: Errore memorizzando il pagamento + submitting_payment: Eseguendo il pagamento products: new: title: 'Nuovo prodotto' @@ -2514,7 +2544,9 @@ it: inherits_properties?: Eredita proprietà? available_on: Disponibile il av_on: "Disp. il" + import_date: "Data di importazione" products_variant: + variant_has_n_overrides: "Questa variante ha %{n} sostituzione/i" new_variant: "Nuova variante" product_name: Nome Prodotto primary_taxon_form: @@ -2523,14 +2555,19 @@ it: group_buy: "Acquisto di gruppo?" display_as: display_as: Visualizza come + reports: + table: + select_and_search: "Seleziona filtri e clicca su %{option} per accedere al tuo dato" users: index: + listing_users: "Elenco Utenti" new_user: "Nuovo utente" user: "Utente" enterprise_limit: "Limite azienda" search: "Cerca" email: "Email" edit: + editing_user: "Modificando Utente" back_to_users_list: "Torna all'elenco degli utenti" general_settings: "Impostazioni generali" form: @@ -2548,6 +2585,7 @@ it: edit: legal_settings: "Impostazioni Legali" cookies_consent_banner_toggle: "Mostra banner di consenso per i cookie" + privacy_policy_url: "Privacy Policy URL" enterprises_require_tos: "Le aziende devono accettare i Termini di Servizio" cookies_policy_matomo_section: "Visualizza la sezione di Matomo nella pagina della cookie policy" cookies_policy_ga_section: "Visualizza la sezione di Google Analytics nella pagina della cookie policy" @@ -2556,7 +2594,12 @@ it: payment: stripe: choose_one: Scegli uno + enter_new_card: Inserire dettagli per una nuova carta + used_saved_card: "usare la carta salvata" + or_enter_new_card: "Oppure, inserire dettagli di una nuova carta" + remember_this_card: Ricordare questa Carta? date_picker: + format: '%Y-%m-%d' js_format: 'aa-mm-gg' inventory: Inventario orders: @@ -2567,6 +2610,7 @@ it: order_mailer: invoice_email: hi: "Ciao %{name}" + invoice_attached_text: 'Aggiunge una fattura per il tuo recente ordine di ' order_state: address: indirizzo adjustments: aggiustamenti @@ -2659,5 +2703,6 @@ it: authorised_shops: Negozi autorizzati authorised_shops: shop_name: "Nome del negozio" + allow_charges?: "Consentire ricarichi" localized_number: invalid_format: 'Formato non valido: inserire un numero.' diff --git a/config/locales/nb.yml b/config/locales/nb.yml index 9a19f953d9..8490c91c8f 100644 --- a/config/locales/nb.yml +++ b/config/locales/nb.yml @@ -1027,6 +1027,7 @@ nb: this_is_an_estimate: | De viste prisene er bare et estimat og beregnet på det tidspunktet abonnementet endres. Hvis du endrer priser eller avgifter, vil ordrer bli oppdatert, men abonnementet vil fortsatt vise de gamle verdiene. + not_in_open_and_upcoming_order_cycles_warning: "Det er ingen åpne eller kommende bestillingsrunder for dette produktet." details: details: Detaljer invalid_error: Oops! Vennligst fyll inn alle obligatoriske felter... @@ -1041,6 +1042,7 @@ nb: details: Detaljer address: Adresse products: Produkter + no_open_or_upcoming_order_cycle: "Ingen kommende bestillingsrunde" product_already_in_order: Dette produktet er allerede lagt til i bestillingen. Vennligst rediger mengden direkte. orders: number: Antall diff --git a/config/routes.rb b/config/routes.rb index 4804e4cae2..34d681fe11 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -118,8 +118,9 @@ Openfoodnetwork::Application.routes.draw do get 'sitemap.xml', to: 'sitemap#index', defaults: { format: 'xml' } - # Mount Web engine routes + # Mount engine routes mount Web::Engine, :at => '/' + mount OrderManagement::Engine, :at => '/' # Mount Spree's routes mount Spree::Core::Engine, :at => '/' diff --git a/config/routes/spree.rb b/config/routes/spree.rb index 2e9dc0b5c8..ad8973a84a 100644 --- a/config/routes/spree.rb +++ b/config/routes/spree.rb @@ -31,7 +31,6 @@ Spree::Core::Engine.routes.prepend do resources :credit_cards - namespace :api, :defaults => { :format => 'json' } do resources :users do get :authorise_api, on: :collection diff --git a/engines/order_management/README.md b/engines/order_management/README.md new file mode 100644 index 0000000000..179330e20a --- /dev/null +++ b/engines/order_management/README.md @@ -0,0 +1,5 @@ +# Order Management + +This is the rails engine for the Order Management domain. + +See our wiki for [more info about domains and engines in OFN](https://github.com/openfoodfoundation/openfoodnetwork/wiki/Tech-Doc:-How-OFN-is-organized-in-Domains-using-Rails-Engines). diff --git a/engines/order_management/app/assets/javascripts/order_management/all.js b/engines/order_management/app/assets/javascripts/order_management/all.js new file mode 100644 index 0000000000..2c03f2d7ab --- /dev/null +++ b/engines/order_management/app/assets/javascripts/order_management/all.js @@ -0,0 +1 @@ +//= require_tree . diff --git a/engines/order_management/app/assets/javascripts/order_management/order_management.js b/engines/order_management/app/assets/javascripts/order_management/order_management.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/engines/order_management/app/assets/stylesheets/order_management/all.css.scss b/engines/order_management/app/assets/stylesheets/order_management/all.css.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/engines/order_management/app/controllers/order_management/application_controller.rb b/engines/order_management/app/controllers/order_management/application_controller.rb new file mode 100644 index 0000000000..ba61b2290b --- /dev/null +++ b/engines/order_management/app/controllers/order_management/application_controller.rb @@ -0,0 +1,5 @@ +module OrderManagement + class ApplicationController < ActionController::Base + protect_from_forgery with: :exception + end +end diff --git a/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/authorizer.rb b/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/authorizer.rb new file mode 100644 index 0000000000..e129905dc6 --- /dev/null +++ b/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/authorizer.rb @@ -0,0 +1,39 @@ +module OrderManagement + module Reports + module EnterpriseFeeSummary + class Authorizer < ::Reports::Authorizer + def self.parameter_not_allowed_error_message + i18n_scope = "order_management.reports.enterprise_fee_summary" + I18n.t("parameter_not_allowed_error", scope: i18n_scope) + end + + def authorize! + authorize_by_distribution! + authorize_by_fee! + end + + private + + def authorize_by_distribution! + require_ids_allowed(parameters.order_cycle_ids, permissions.allowed_order_cycles) + require_ids_allowed(parameters.distributor_ids, permissions.allowed_distributors) + require_ids_allowed(parameters.producer_ids, permissions.allowed_producers) + end + + def authorize_by_fee! + require_ids_allowed(parameters.enterprise_fee_ids, permissions.allowed_enterprise_fees) + require_ids_allowed(parameters.shipping_method_ids, permissions.allowed_shipping_methods) + require_ids_allowed(parameters.payment_method_ids, permissions.allowed_payment_methods) + end + + def require_ids_allowed(array, allowed_objects) + error_klass = ::Reports::Authorizer::ParameterNotAllowedError + error_message = self.class.parameter_not_allowed_error_message + ids_allowed = (array - allowed_objects.map(&:id).map(&:to_s)).blank? + + raise error_klass, error_message unless ids_allowed + end + end + end + end +end diff --git a/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/enterprise_fee_type_total_summarizer.rb b/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/enterprise_fee_type_total_summarizer.rb new file mode 100644 index 0000000000..cd7875f758 --- /dev/null +++ b/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/enterprise_fee_type_total_summarizer.rb @@ -0,0 +1,87 @@ +module OrderManagement + module Reports + module EnterpriseFeeSummary + class EnterpriseFeeTypeTotalSummarizer + attr_accessor :data + + def initialize(data) + @data = data + end + + def fee_type + if for_payment_method? + i18n_translate("fee_type.payment_method") + elsif for_shipping_method? + i18n_translate("fee_type.shipping_method") + else + data["fee_type"].try(:capitalize) + end + end + + def enterprise_name + if for_payment_method? + data["hub_name"] + elsif for_shipping_method? + data["hub_name"] + else + data["enterprise_name"] + end + end + + def fee_name + if for_payment_method? + data["payment_method_name"] + elsif for_shipping_method? + data["shipping_method_name"] + else + data["fee_name"] + end + end + + def customer_name + data["customer_name"] + end + + def fee_placement + return if for_payment_method? || for_shipping_method? + + i18n_translate("fee_placements.#{data['placement_enterprise_role']}") + end + + def fee_calculated_on_transfer_through_name + return if for_payment_method? || for_shipping_method? + + transfer_through_all_string = i18n_translate("fee_calculated_on_transfer_through_all") + + data["incoming_exchange_enterprise_name"] || data["outgoing_exchange_enterprise_name"] || + (transfer_through_all_string if data["placement_enterprise_role"] == "coordinator") + end + + def tax_category_name + return if for_payment_method? + return i18n_translate("tax_category_name.shipping_instance_rate") if for_shipping_method? + + data["tax_category_name"] || data["product_tax_category_name"] + end + + def total_amount + data["total_amount"] + end + + private + + def for_payment_method? + data["payment_method_name"].present? + end + + def for_shipping_method? + data["shipping_method_name"].present? + end + + def i18n_translate(translation_key) + I18n.t("order_management.reports.enterprise_fee_summary.#{translation_key}") + end + end + end + end +end diff --git a/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/parameters.rb b/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/parameters.rb new file mode 100644 index 0000000000..cc12d9be1f --- /dev/null +++ b/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/parameters.rb @@ -0,0 +1,68 @@ +module OrderManagement + module Reports + module EnterpriseFeeSummary + class Parameters < ::Reports::Parameters::Base + extend ActiveModel::Naming + extend ActiveModel::Translation + include ActiveModel::Validations + + attr_accessor :start_at, :end_at, :distributor_ids, :producer_ids, :order_cycle_ids, + :enterprise_fee_ids, :shipping_method_ids, :payment_method_ids + + before_validation :cleanup_arrays + + validates :start_at, :end_at, date_time_string: true + validates :distributor_ids, :producer_ids, integer_array: true + validates :order_cycle_ids, integer_array: true + validates :enterprise_fee_ids, integer_array: true + validates :shipping_method_ids, :payment_method_ids, integer_array: true + + validate :require_valid_datetime_range + + def self.date_end_before_start_error_message + i18n_scope = "order_management.reports.enterprise_fee_summary" + I18n.t("date_end_before_start_error", scope: i18n_scope) + end + + def initialize(attributes = {}) + self.distributor_ids = [] + self.producer_ids = [] + self.order_cycle_ids = [] + self.enterprise_fee_ids = [] + self.shipping_method_ids = [] + self.payment_method_ids = [] + + super(attributes) + end + + def authorize!(permissions) + authorizer = Authorizer.new(self, permissions) + authorizer.authorize! + end + + protected + + def require_valid_datetime_range + return if start_at.blank? || end_at.blank? + + error_message = self.class.date_end_before_start_error_message + errors.add(:end_at, error_message) unless start_at < end_at + end + + # Remove the blank strings that Rails multiple selects add by default to + # make sure that blank lists are still submitted to the server as arrays + # instead of nil. + # + # https://api.rubyonrails.org/classes/ActionView/Helpers/FormOptionsHelper.html#method-i-select + def cleanup_arrays + distributor_ids.reject!(&:blank?) + producer_ids.reject!(&:blank?) + order_cycle_ids.reject!(&:blank?) + enterprise_fee_ids.reject!(&:blank?) + shipping_method_ids.reject!(&:blank?) + payment_method_ids.reject!(&:blank?) + end + end + end + end +end diff --git a/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/permissions.rb b/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/permissions.rb new file mode 100644 index 0000000000..2077931627 --- /dev/null +++ b/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/permissions.rb @@ -0,0 +1,45 @@ +module OrderManagement + module Reports + module EnterpriseFeeSummary + class Permissions < ::Reports::Permissions + def allowed_order_cycles + @allowed_order_cycles ||= OrderCycle.accessible_by(user) + end + + def allowed_distributors + outgoing_exchanges = Exchange.outgoing.where(order_cycle_id: allowed_order_cycle_ids) + @allowed_distributors ||= Enterprise.where(id: outgoing_exchanges.pluck(:receiver_id)) + end + + def allowed_producers + incoming_exchanges = Exchange.incoming.where(order_cycle_id: allowed_order_cycle_ids) + @allowed_producers ||= Enterprise.where(id: incoming_exchanges.pluck(:sender_id)) + end + + def allowed_enterprise_fees + return EnterpriseFee.where("1=0") if allowed_order_cycles.blank? + + coordinator_enterprise_fees = EnterpriseFee.joins(:coordinator_fees) + .where(coordinator_fees: { order_cycle_id: allowed_order_cycle_ids }) + exchange_enterprise_fees = EnterpriseFee.joins(exchange_fees: :exchange) + .where(exchanges: { order_cycle_id: allowed_order_cycle_ids }) + @allowed_enterprise_fees ||= (coordinator_enterprise_fees | exchange_enterprise_fees).uniq + end + + def allowed_shipping_methods + @allowed_shipping_methods ||= Spree::ShippingMethod.for_distributors(allowed_distributors) + end + + def allowed_payment_methods + @allowed_payment_methods ||= Spree::PaymentMethod.for_distributors(allowed_distributors) + end + + private + + def allowed_order_cycle_ids + @allowed_order_cycle_ids ||= allowed_order_cycles.map(&:id) + end + end + end + end +end diff --git a/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/renderers/csv_renderer.rb b/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/renderers/csv_renderer.rb new file mode 100644 index 0000000000..c4c8450485 --- /dev/null +++ b/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/renderers/csv_renderer.rb @@ -0,0 +1,64 @@ +module OrderManagement + module Reports + module EnterpriseFeeSummary + module Renderers + class CsvRenderer < ::Reports::Renderers::Base + def render(context) + context.send_data(generate, filename: filename) + end + + def generate + CSV.generate do |csv| + render_header(csv) + + report_data.list.each do |data| + render_data_row(csv, data) + end + end + end + + private + + def filename + timestamp = Time.zone.now.strftime("%Y%m%d") + "enterprise_fee_summary_#{timestamp}.csv" + end + + def render_header(csv) + csv << [ + header_label(:fee_type), + header_label(:enterprise_name), + header_label(:fee_name), + header_label(:customer_name), + header_label(:fee_placement), + header_label(:fee_calculated_on_transfer_through_name), + header_label(:tax_category_name), + header_label(:total_amount) + ] + end + + def render_data_row(csv, data) + csv << [ + data.fee_type, + data.enterprise_name, + data.fee_name, + data.customer_name, + data.fee_placement, + data.fee_calculated_on_transfer_through_name, + data.tax_category_name, + data.total_amount + ] + end + + def header_label(attribute) + I18n.t("header.#{attribute}", scope: i18n_scope) + end + + def i18n_scope + "order_management.reports.enterprise_fee_summary.formats.csv" + end + end + end + end + end +end diff --git a/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/renderers/html_renderer.rb b/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/renderers/html_renderer.rb new file mode 100644 index 0000000000..23ed8f26ba --- /dev/null +++ b/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/renderers/html_renderer.rb @@ -0,0 +1,51 @@ +module OrderManagement + module Reports + module EnterpriseFeeSummary + module Renderers + class HtmlRenderer < ::Reports::Renderers::Base + def render(context) + context.instance_variable_set :@renderer, self + context.render(action: :create, renderer: self) + end + + def header + data_row_attributes.map do |attribute| + header_label(attribute) + end + end + + def data_rows + report_data.list.map do |data| + data_row_attributes.map do |attribute| + data.public_send(attribute) + end + end + end + + private + + def data_row_attributes + [ + :fee_type, + :enterprise_name, + :fee_name, + :customer_name, + :fee_placement, + :fee_calculated_on_transfer_through_name, + :tax_category_name, + :total_amount + ] + end + + def header_label(attribute) + I18n.t("header.#{attribute}", scope: i18n_scope) + end + + def i18n_scope + "order_management.reports.enterprise_fee_summary.formats.csv" + end + end + end + end + end +end diff --git a/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/report_data/enterprise_fee_type_total.rb b/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/report_data/enterprise_fee_type_total.rb new file mode 100644 index 0000000000..ca781af4e4 --- /dev/null +++ b/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/report_data/enterprise_fee_type_total.rb @@ -0,0 +1,29 @@ +module OrderManagement + module Reports + module EnterpriseFeeSummary + module ReportData + class EnterpriseFeeTypeTotal < ::Reports::ReportData::Base + attr_accessor :fee_type, :enterprise_name, :fee_name, :customer_name, :fee_placement, + :fee_calculated_on_transfer_through_name, :tax_category_name, :total_amount + + def <=>(other) + sortable_data <=> other.sortable_data + end + + def sortable_data + [ + fee_type, + enterprise_name, + fee_name, + customer_name, + fee_placement, + fee_calculated_on_transfer_through_name, + tax_category_name, + total_amount + ].map { |attribute| attribute || "" } + end + end + end + end + end +end diff --git a/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/report_service.rb b/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/report_service.rb new file mode 100644 index 0000000000..4780629a45 --- /dev/null +++ b/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/report_service.rb @@ -0,0 +1,47 @@ +module OrderManagement + module Reports + module EnterpriseFeeSummary + class ReportService + attr_accessor :permissions, :parameters + + def initialize(permissions, parameters) + @permissions = permissions + @parameters = parameters + end + + def enterprise_fees_by_customer + Scope.new.apply_filters(permission_filters).apply_filters(parameters).result + end + + def list + enterprise_fee_type_total_list.sort + end + + private + + def permission_filters + Parameters.new(order_cycle_ids: permissions.allowed_order_cycles.map(&:id)) + end + + def enterprise_fee_type_total_list + enterprise_fees_by_customer.map do |total_data| + summarizer = EnterpriseFeeTypeTotalSummarizer.new(total_data) + + ReportData::EnterpriseFeeTypeTotal.new.tap do |total| + enterprise_fee_type_summarizer_to_total_attributes.each do |attribute| + total.public_send("#{attribute}=", summarizer.public_send(attribute)) + end + end + end + end + + def enterprise_fee_type_summarizer_to_total_attributes + [ + :fee_type, :enterprise_name, :fee_name, :customer_name, :fee_placement, + :fee_calculated_on_transfer_through_name, :tax_category_name, :total_amount + ] + end + end + end + end +end diff --git a/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/scope.rb b/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/scope.rb new file mode 100644 index 0000000000..b3f3168518 --- /dev/null +++ b/engines/order_management/app/services/order_management/reports/enterprise_fee_summary/scope.rb @@ -0,0 +1,362 @@ +module OrderManagement + module Reports + module EnterpriseFeeSummary + class Scope + attr_accessor :parameters + + def initialize + setup_default_scope + end + + def apply_filters(params) + filter_by_date(params) + filter_by_distribution(params) + filter_by_fee(params) + + self + end + + def result + group_data.select_attributes + @scope.all + end + + protected + + def setup_default_scope + find_supported_adjustments + + include_adjustment_metadata + include_order_details + include_payment_fee_details + include_shipping_fee_details + include_enterprise_fee_details + include_line_item_details + include_incoming_exchange_details + include_outgoing_exchange_details + + group_data + select_attributes + end + + def find_supported_adjustments + find_adjustments.for_orders.for_supported_adjustments + end + + def find_adjustments + chain_to_scope do + Spree::Adjustment + end + end + + def for_orders + chain_to_scope do + where(adjustable_type: "Spree::Order") + end + end + + def for_supported_adjustments + chain_to_scope do + where(originator_type: ["EnterpriseFee", "Spree::PaymentMethod", + "Spree::ShippingMethod"]) + end + end + + def include_adjustment_metadata + join_scope( + <<-JOIN_STRING.strip_heredoc + LEFT OUTER JOIN adjustment_metadata + ON (adjustment_metadata.adjustment_id = spree_adjustments.id) + JOIN_STRING + ) + end + + # Includes: + # * Order + # * Customer + # * Hub + def include_order_details + join_scope( + <<-JOIN_STRING.strip_heredoc + LEFT OUTER JOIN spree_orders + ON ( + spree_adjustments.adjustable_type = 'Spree::Order' + AND spree_orders.id = spree_adjustments.adjustable_id + AND spree_orders.completed_at IS NOT NULL + ) + JOIN_STRING + ) + + join_scope("LEFT OUTER JOIN customers ON (customers.id = spree_orders.customer_id)") + + join_scope( + <<-JOIN_STRING.strip_heredoc + LEFT OUTER JOIN enterprises AS hubs + ON (hubs.id = spree_orders.distributor_id) + JOIN_STRING + ) + end + + # If for payment fee + # + # Includes: + # * Payment method + def include_payment_fee_details + join_scope( + <<-JOIN_STRING.strip_heredoc + LEFT OUTER JOIN spree_payment_methods + ON ( + spree_adjustments.originator_type = 'Spree::PaymentMethod' + AND spree_payment_methods.id = spree_adjustments.originator_id + ) + JOIN_STRING + ) + + join_scope( + <<-JOIN_STRING.strip_heredoc + LEFT OUTER JOIN enterprises AS payment_hubs + ON ( + spree_payment_methods.id IS NOT NULL + AND payment_hubs.id = spree_orders.distributor_id + ) + JOIN_STRING + ) + end + + # If for shipping fee + # + # Includes: + # * Shipping method + def include_shipping_fee_details + join_scope( + <<-JOIN_STRING.strip_heredoc + LEFT OUTER JOIN spree_shipping_methods + ON ( + spree_adjustments.originator_type = 'Spree::ShippingMethod' + AND spree_shipping_methods.id = spree_adjustments.originator_id + ) + JOIN_STRING + ) + end + + # Includes: + # * Enterprise fee + # * Enterprise + # * Enterprise fee tax category + def include_enterprise_fee_details + join_scope( + <<-JOIN_STRING.strip_heredoc + LEFT OUTER JOIN enterprise_fees + ON ( + spree_adjustments.originator_type = 'EnterpriseFee' + AND enterprise_fees.id = spree_adjustments.originator_id + ) + JOIN_STRING + ) + + join_scope( + <<-JOIN_STRING.strip_heredoc + LEFT OUTER JOIN enterprises + ON (enterprises.id = enterprise_fees.enterprise_id) + JOIN_STRING + ) + + join_scope( + <<-JOIN_STRING.strip_heredoc + LEFT OUTER JOIN spree_tax_categories + ON (spree_tax_categories.id = enterprise_fees.tax_category_id) + JOIN_STRING + ) + end + + # If for line item - Use data only if spree_line_items.id is present + # + # Includes: + # * Line item + # * Variant + # * Product + # * Tax category of product, if enterprise fee tells to inherit + def include_line_item_details + join_scope( + <<-JOIN_STRING.strip_heredoc + LEFT OUTER JOIN spree_line_items + ON ( + spree_adjustments.source_type = 'Spree::LineItem' + AND spree_line_items.id = spree_adjustments.source_id + ) + JOIN_STRING + ) + + join_scope( + <<-JOIN_STRING.strip_heredoc + LEFT OUTER JOIN spree_variants + ON ( + spree_adjustments.source_type = 'Spree::LineItem' + AND spree_variants.id = spree_line_items.variant_id + ) + JOIN_STRING + ) + + join_scope( + <<-JOIN_STRING.strip_heredoc + LEFT OUTER JOIN spree_products + ON (spree_products.id = spree_variants.product_id) + JOIN_STRING + ) + + join_scope( + <<-JOIN_STRING.strip_heredoc + LEFT OUTER JOIN spree_tax_categories AS product_tax_categories + ON ( + enterprise_fees.inherits_tax_category IS TRUE + AND product_tax_categories.id = spree_products.tax_category_id + ) + JOIN_STRING + ) + end + + # If incoming exchange - Use data only if incoming_exchange_variants.id is present + # + # Includes: + # * Incoming exchange + # * Incoming exchange enterprise + # * Incoming exchange variant + def include_incoming_exchange_details + join_scope( + <<-JOIN_STRING.strip_heredoc + LEFT OUTER JOIN + ( + exchange_variants AS incoming_exchange_variants + LEFT OUTER JOIN exchanges AS incoming_exchanges + ON ( + incoming_exchanges.incoming IS TRUE + AND incoming_exchange_variants.exchange_id = incoming_exchanges.id + ) + ) + ON ( + spree_adjustments.source_type = 'Spree::LineItem' + AND adjustment_metadata.enterprise_role = 'supplier' + AND incoming_exchanges.order_cycle_id = spree_orders.order_cycle_id + AND incoming_exchange_variants.id IS NOT NULL + AND incoming_exchange_variants.variant_id = spree_line_items.variant_id + ) + JOIN_STRING + ) + + join_scope( + <<-JOIN_STRING.strip_heredoc + LEFT OUTER JOIN enterprises AS incoming_exchange_enterprises + ON (incoming_exchange_enterprises.id = incoming_exchanges.sender_id) + JOIN_STRING + ) + end + + # If outgoing exchange - Use data only if outgoing_exchange_variants.id is present + # + # Includes: + # * Outgoing exchange + # * Outgoing exchange enterprise + # * Outgoing exchange variant + def include_outgoing_exchange_details + join_scope( + <<-JOIN_STRING.strip_heredoc + LEFT OUTER JOIN + ( + exchange_variants AS outgoing_exchange_variants + LEFT OUTER JOIN exchanges AS outgoing_exchanges + ON ( + outgoing_exchanges.incoming IS NOT TRUE + AND outgoing_exchange_variants.exchange_id = outgoing_exchanges.id + ) + ) + ON ( + spree_adjustments.source_type = 'Spree::LineItem' + AND adjustment_metadata.enterprise_role = 'distributor' + AND outgoing_exchanges.order_cycle_id = spree_orders.order_cycle_id + AND outgoing_exchange_variants.id IS NOT NULL + AND outgoing_exchange_variants.variant_id = spree_line_items.variant_id + ) + JOIN_STRING + ) + + join_scope( + <<-JOIN_STRING.strip_heredoc + LEFT OUTER JOIN enterprises AS outgoing_exchange_enterprises + ON (outgoing_exchange_enterprises.id = outgoing_exchanges.receiver_id) + JOIN_STRING + ) + end + + def filter_by_date(params) + filter_scope("spree_orders.completed_at >= ?", params.start_at) \ + if params.start_at.present? + filter_scope("spree_orders.completed_at <= ?", params.end_at) if params.end_at.present? + end + + def filter_by_distribution(params) + filter_scope(spree_orders: { distributor_id: params.distributor_ids }) \ + if params.distributor_ids.present? + filter_scope(spree_products: { supplier_id: params.producer_ids }) \ + if params.producer_ids.present? + filter_scope(spree_orders: { order_cycle_id: params.order_cycle_ids }) \ + if params.order_cycle_ids.present? + end + + def filter_by_fee(params) + filter_scope(enterprise_fees: { id: params.enterprise_fee_ids }) \ + if params.enterprise_fee_ids.present? + filter_scope(spree_shipping_methods: { id: params.shipping_method_ids }) \ + if params.shipping_method_ids.present? + filter_scope(spree_payment_methods: { id: params.payment_method_ids }) \ + if params.payment_method_ids.present? + end + + def group_data + chain_to_scope do + group("enterprise_fees.id", "enterprises.id", "customers.id", "hubs.id", + "spree_payment_methods.id", "spree_shipping_methods.id", + "adjustment_metadata.enterprise_role", "spree_tax_categories.id", + "product_tax_categories.id", "incoming_exchange_enterprises.id", + "outgoing_exchange_enterprises.id") + end + end + + def select_attributes + chain_to_scope do + select( + <<-JOIN_STRING.strip_heredoc + SUM(spree_adjustments.amount) AS total_amount, spree_payment_methods.name AS + payment_method_name, spree_shipping_methods.name AS shipping_method_name, + hubs.name AS hub_name, enterprises.name AS enterprise_name, + enterprise_fees.fee_type AS fee_type, customers.name AS customer_name, + customers.email AS customer_email, enterprise_fees.fee_type AS fee_type, + enterprise_fees.name AS fee_name, spree_tax_categories.name AS tax_category_name, + product_tax_categories.name AS product_tax_category_name, + adjustment_metadata.enterprise_role AS placement_enterprise_role, + incoming_exchange_enterprises.name AS incoming_exchange_enterprise_name, + outgoing_exchange_enterprises.name AS outgoing_exchange_enterprise_name + JOIN_STRING + ) + end + end + + def chain_to_scope(&block) + @scope = @scope.instance_eval(&block) + self + end + + def join_scope(join_string) + chain_to_scope do + joins(join_string) + end + end + + def filter_scope(*args) + chain_to_scope do + where(*args) + end + end + end + end + end +end diff --git a/engines/order_management/app/services/reports.rb b/engines/order_management/app/services/reports.rb new file mode 100644 index 0000000000..ec85236122 --- /dev/null +++ b/engines/order_management/app/services/reports.rb @@ -0,0 +1,3 @@ +module Reports + class UnsupportedReportFormatException < StandardError; end +end diff --git a/engines/order_management/app/services/reports/authorizer.rb b/engines/order_management/app/services/reports/authorizer.rb new file mode 100644 index 0000000000..279952249a --- /dev/null +++ b/engines/order_management/app/services/reports/authorizer.rb @@ -0,0 +1,12 @@ +module Reports + class Authorizer + class ParameterNotAllowedError < StandardError; end + + attr_accessor :parameters, :permissions + + def initialize(parameters, permissions) + @parameters = parameters + @permissions = permissions + end + end +end diff --git a/engines/order_management/app/services/reports/parameters/base.rb b/engines/order_management/app/services/reports/parameters/base.rb new file mode 100644 index 0000000000..8debc06b1d --- /dev/null +++ b/engines/order_management/app/services/reports/parameters/base.rb @@ -0,0 +1,19 @@ +module Reports + module Parameters + class Base + extend ActiveModel::Naming + extend ActiveModel::Translation + include ActiveModel::Validations + include ActiveModel::Validations::Callbacks + + def initialize(attributes = {}) + attributes.each do |key, value| + public_send("#{key}=", value) + end + end + + # The parameters are never persisted. + def to_key; end + end + end +end diff --git a/engines/order_management/app/services/reports/permissions.rb b/engines/order_management/app/services/reports/permissions.rb new file mode 100644 index 0000000000..df8588670f --- /dev/null +++ b/engines/order_management/app/services/reports/permissions.rb @@ -0,0 +1,9 @@ +module Reports + class Permissions + attr_accessor :user + + def initialize(user) + @user = user + end + end +end diff --git a/engines/order_management/app/services/reports/renderers/base.rb b/engines/order_management/app/services/reports/renderers/base.rb new file mode 100644 index 0000000000..73fda10231 --- /dev/null +++ b/engines/order_management/app/services/reports/renderers/base.rb @@ -0,0 +1,11 @@ +module Reports + module Renderers + class Base + attr_reader :report_data + + def initialize(report_data) + @report_data = report_data + end + end + end +end diff --git a/engines/order_management/app/services/reports/report_data/base.rb b/engines/order_management/app/services/reports/report_data/base.rb new file mode 100644 index 0000000000..a7d0595078 --- /dev/null +++ b/engines/order_management/app/services/reports/report_data/base.rb @@ -0,0 +1,11 @@ +module Reports + module ReportData + class Base + def initialize(attributes = {}) + attributes.each do |key, value| + public_send("#{key}=", value) + end + end + end + end +end diff --git a/engines/order_management/config/routes.rb b/engines/order_management/config/routes.rb new file mode 100644 index 0000000000..e221867e7e --- /dev/null +++ b/engines/order_management/config/routes.rb @@ -0,0 +1,7 @@ +Spree::Core::Engine.routes.prepend do + namespace :admin do + namespace :reports do + resource :enterprise_fee_summary, only: [:new, :create] + end + end +end diff --git a/engines/order_management/lib/order_management.rb b/engines/order_management/lib/order_management.rb new file mode 100644 index 0000000000..816ef31b89 --- /dev/null +++ b/engines/order_management/lib/order_management.rb @@ -0,0 +1,4 @@ +require "order_management/engine" + +module OrderManagement +end diff --git a/engines/order_management/lib/order_management/engine.rb b/engines/order_management/lib/order_management/engine.rb new file mode 100644 index 0000000000..a79bb4d0c7 --- /dev/null +++ b/engines/order_management/lib/order_management/engine.rb @@ -0,0 +1,5 @@ +module OrderManagement + class Engine < ::Rails::Engine + isolate_namespace OrderManagement + end +end diff --git a/engines/order_management/lib/order_management/version.rb b/engines/order_management/lib/order_management/version.rb new file mode 100644 index 0000000000..ccdf0928b4 --- /dev/null +++ b/engines/order_management/lib/order_management/version.rb @@ -0,0 +1,3 @@ +module OrderManagement + VERSION = "0.0.1".freeze +end diff --git a/engines/order_management/order_management.gemspec b/engines/order_management/order_management.gemspec new file mode 100644 index 0000000000..0ab11f2800 --- /dev/null +++ b/engines/order_management/order_management.gemspec @@ -0,0 +1,13 @@ +$LOAD_PATH.push File.expand_path('lib', __dir__) + +require "order_management/version" + +Gem::Specification.new do |s| + s.name = "order_management" + s.version = OrderManagement::VERSION + s.authors = ["developers@ofn"] + s.summary = "Order Management domain of the OFN solution." + + s.files = Dir["{app,config,db,lib}/**/*"] + ["LICENSE.txt", "Rakefile", "README.rdoc"] + s.test_files = Dir["spec/**/*"] +end diff --git a/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/authorizer_spec.rb b/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/authorizer_spec.rb new file mode 100644 index 0000000000..62d6e74465 --- /dev/null +++ b/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/authorizer_spec.rb @@ -0,0 +1,172 @@ +require "spec_helper" + +describe OrderManagement::Reports::EnterpriseFeeSummary::Authorizer do + let(:report_klass) { OrderManagement::Reports::EnterpriseFeeSummary } + let(:user) { create(:user) } + + let(:parameters) { report_klass::Parameters.new(params) } + let(:permissions) { report_klass::Permissions.new(user) } + let(:authorizer) { described_class.new(parameters, permissions) } + + context "for distributors" do + before do + allow(permissions).to receive(:allowed_distributors) do + stub_model_collection(Enterprise, :id, ["1", "2", "3"]) + end + end + + context "when distributors are allowed" do + let(:params) { { distributor_ids: ["1", "3"] } } + + it "does not raise error" do + expect { authorizer.authorize! }.not_to raise_error + end + end + + context "when a distributor is not allowed" do + let(:params) { { distributor_ids: ["1", "4"] } } + + it "raises ParameterNotAllowedError" do + expect { authorizer.authorize! } + .to raise_error(Reports::Authorizer::ParameterNotAllowedError) + end + end + end + + context "for producers" do + before do + allow(permissions).to receive(:allowed_producers) do + stub_model_collection(Enterprise, :id, ["1", "2", "3"]) + end + end + + context "when producers are allowed" do + let(:params) { { producer_ids: ["1", "3"] } } + + it "does not raise error" do + expect { authorizer.authorize! }.not_to raise_error + end + end + + context "when a producer is not allowed" do + let(:params) { { producer_ids: ["1", "4"] } } + + it "raises ParameterNotAllowedError" do + expect { authorizer.authorize! } + .to raise_error(Reports::Authorizer::ParameterNotAllowedError) + end + end + end + + context "for order cycles" do + before do + allow(permissions).to receive(:allowed_order_cycles) do + stub_model_collection(OrderCycle, :id, ["1", "2", "3"]) + end + end + + context "when order cycles are allowed" do + let(:params) { { order_cycle_ids: ["1", "3"] } } + + it "does not raise error" do + expect { authorizer.authorize! }.not_to raise_error + end + end + + context "when an order cycle is not allowed" do + let(:params) { { order_cycle_ids: ["1", "4"] } } + + it "raises ParameterNotAllowedError" do + expect { authorizer.authorize! } + .to raise_error(Reports::Authorizer::ParameterNotAllowedError) + end + end + end + + context "for enterprise fees" do + before do + allow(permissions).to receive(:allowed_enterprise_fees) do + stub_model_collection(EnterpriseFee, :id, ["1", "2", "3"]) + end + end + + context "when enterprise fees are allowed" do + let(:params) { { enterprise_fee_ids: ["1", "3"] } } + + it "does not raise error" do + expect { authorizer.authorize! }.not_to raise_error + end + end + + context "when an enterprise fee is not allowed" do + let(:params) { { enterprise_fee_ids: ["1", "4"] } } + + it "raises ParameterNotAllowedError" do + expect { authorizer.authorize! } + .to raise_error(Reports::Authorizer::ParameterNotAllowedError) + end + end + end + + context "for shipping methods" do + before do + allow(permissions).to receive(:allowed_shipping_methods) do + stub_model_collection(Spree::ShippingMethod, :id, ["1", "2", "3"]) + end + end + + context "when shipping methods are allowed" do + let(:params) { { shipping_method_ids: ["1", "3"] } } + + it "does not raise error" do + expect { authorizer.authorize! }.not_to raise_error + end + end + + context "when a shipping method is not allowed" do + let(:params) { { shipping_method_ids: ["1", "4"] } } + + it "raises ParameterNotAllowedError" do + expect { authorizer.authorize! } + .to raise_error(Reports::Authorizer::ParameterNotAllowedError) + end + end + end + + context "for payment methods" do + before do + allow(permissions).to receive(:allowed_payment_methods) do + stub_model_collection(Spree::PaymentMethod, :id, ["1", "2", "3"]) + end + end + + context "when payment methods are allowed" do + let(:params) { { payment_method_ids: ["1", "3"] } } + + it "does not raise error" do + expect { authorizer.authorize! }.not_to raise_error + end + end + + context "when a payment method is not allowed" do + let(:params) { { payment_method_ids: ["1", "4"] } } + + it "raises ParameterNotAllowedError" do + expect { authorizer.authorize! } + .to raise_error(Reports::Authorizer::ParameterNotAllowedError) + end + end + end + + def stub_model_collection(model, attribute_name, attribute_list) + attribute_list.map do |attribute_value| + stub_model(model, attribute_name => attribute_value) + end + end + + def stub_model(model, params) + model.new.tap do |instance| + instance.stub(params) + end + end +end diff --git a/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/parameters_spec.rb b/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/parameters_spec.rb new file mode 100644 index 0000000000..6fd3c8b9a7 --- /dev/null +++ b/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/parameters_spec.rb @@ -0,0 +1,88 @@ +require "spec_helper" + +require "date_time_string_validator" + +describe OrderManagement::Reports::EnterpriseFeeSummary::Parameters do + describe "validation" do + let(:parameters) { described_class.new } + + it "allows all parameters to be blank" do + expect(parameters).to be_valid + end + + context "for type of parameters" do + it { is_expected.to validate_date_time_format_of(:start_at) } + it { is_expected.to validate_date_time_format_of(:end_at) } + it { is_expected.to validate_integer_array(:distributor_ids) } + it { is_expected.to validate_integer_array(:producer_ids) } + it { is_expected.to validate_integer_array(:order_cycle_ids) } + it { is_expected.to validate_integer_array(:enterprise_fee_ids) } + it { is_expected.to validate_integer_array(:shipping_method_ids) } + it { is_expected.to validate_integer_array(:payment_method_ids) } + + it "allows integer arrays to include blank string and cleans it up" do + subject.distributor_ids = ["", "1"] + subject.producer_ids = ["", "1"] + subject.order_cycle_ids = ["", "1"] + subject.enterprise_fee_ids = ["", "1"] + subject.shipping_method_ids = ["", "1"] + subject.payment_method_ids = ["", "1"] + + expect(subject).to be_valid + + expect(subject.distributor_ids).to eq(["1"]) + expect(subject.producer_ids).to eq(["1"]) + expect(subject.order_cycle_ids).to eq(["1"]) + expect(subject.enterprise_fee_ids).to eq(["1"]) + expect(subject.shipping_method_ids).to eq(["1"]) + expect(subject.payment_method_ids).to eq(["1"]) + end + + describe "requiring start_at to be before end_at" do + let(:now) { Time.zone.now } + + it "adds error when start_at is after end_at" do + allow(subject).to receive(:start_at) { now.to_s } + allow(subject).to receive(:end_at) { (now - 1.hour).to_s } + + expect(subject).not_to be_valid + error_message = described_class.date_end_before_start_error_message + expect(subject.errors[:end_at]).to eq([error_message]) + end + + it "does not add error when start_at is before end_at" do + allow(subject).to receive(:start_at) { now.to_s } + allow(subject).to receive(:end_at) { (now + 1.hour).to_s } + + expect(subject).to be_valid + end + end + end + end + + describe "smoke authorization" do + let!(:order_cycle) { create(:order_cycle) } + let!(:user) { create(:user) } + + let(:permissions) do + report_klass::Permissions.new(nil).tap do |instance| + instance.stub(allowed_order_cycles: [order_cycle]) + end + end + + it "does not raise error when the parameters are allowed" do + parameters = described_class.new(order_cycle_ids: [order_cycle.id.to_s]) + expect { parameters.authorize!(permissions) }.not_to raise_error + end + + it "raises error when the parameters are not allowed" do + parameters = described_class.new(order_cycle_ids: [(order_cycle.id + 1).to_s]) + expect { parameters.authorize!(permissions) } + .to raise_error(Reports::Authorizer::ParameterNotAllowedError) + end + end + + def report_klass + OrderManagement::Reports::EnterpriseFeeSummary + end +end diff --git a/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/permissions_spec.rb b/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/permissions_spec.rb new file mode 100644 index 0000000000..2024da96c7 --- /dev/null +++ b/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/permissions_spec.rb @@ -0,0 +1,206 @@ +require "spec_helper" + +describe OrderManagement::Reports::EnterpriseFeeSummary::Permissions do + let!(:order_cycle) { create(:simple_order_cycle) } + let!(:incoming_exchange) { create(:exchange, incoming: true, order_cycle: order_cycle) } + let!(:outgoing_exchange) { create(:exchange, incoming: false, order_cycle: order_cycle) } + + # The factory for order cycle uses the first distributor it finds in the database, if it exists. + # However, for this example group, we need to make sure that the coordinator for the second order + # cycle is not the same as the one in the first. + let!(:another_coordinator) { create(:distributor_enterprise) } + + let!(:another_order_cycle) { create(:simple_order_cycle, coordinator: another_coordinator) } + let!(:another_incoming_exchange) do + create(:exchange, incoming: true, order_cycle: another_order_cycle) + end + let!(:another_outgoing_exchange) do + create(:exchange, incoming: false, order_cycle: another_order_cycle) + end + + describe "permissions for order cycles" do + it "allows admin" do + user = create(:admin_user) + authorizer = described_class.new(user) + expect(authorizer.allowed_order_cycles).to include(order_cycle) + end + + it "allows coordinator of the order cycle" do + user = order_cycle.coordinator.owner + authorizer = described_class.new(user) + expect(authorizer.allowed_order_cycles).to include(order_cycle) + end + + it "allows sender of incoming exchange" do + user = incoming_exchange.sender.owner + authorizer = described_class.new(user) + expect(authorizer.allowed_order_cycles).to include(order_cycle) + end + + it "allows receiver of outgoing exchange" do + user = outgoing_exchange.receiver.owner + authorizer = described_class.new(user) + expect(authorizer.allowed_order_cycles).to include(order_cycle) + end + + it "does not allow coordinator of another order cycle" do + user = another_order_cycle.coordinator.owner + authorizer = described_class.new(user) + expect(authorizer.allowed_order_cycles).not_to include(order_cycle) + end + + it "does not allow sender of incoming exchange of another order cycle" do + user = another_incoming_exchange.sender.owner + authorizer = described_class.new(user) + expect(authorizer.allowed_order_cycles).not_to include(order_cycle) + end + + it "does not allow receiver of outgoing exchange of another order cycle" do + user = another_outgoing_exchange.receiver.owner + authorizer = described_class.new(user) + expect(authorizer.allowed_order_cycles).not_to include(order_cycle) + end + end + + describe "permissions for properties related to the order cycle" do + let(:user) { create(:user) } + let(:authorizer) do + described_class.new(user).tap do |instance| + allow(instance).to receive(:allowed_order_cycles) { [order_cycle] } + end + end + + describe "allowed distributors" do + it "includes distributor of allowed order cycle" do + expect(authorizer.allowed_distributors).to include(outgoing_exchange.receiver) + end + + it "does not include distributor of order cycle that is not allowed" do + expect(authorizer.allowed_distributors).not_to include(another_outgoing_exchange.receiver) + end + end + + describe "allowed producers" do + it "includes supplier of allowed order cycle" do + expect(authorizer.allowed_producers).to include(incoming_exchange.sender) + end + + it "does not include supplier of order cycle that is not allowed" do + expect(authorizer.allowed_producers).not_to include(another_incoming_exchange.sender) + end + end + + describe "allowed enterprise fees" do + context "when coordinator fee for order cycle" do + let!(:coordinator_fee) do + create(:enterprise_fee, enterprise: order_cycle.coordinator).tap do |fee| + order_cycle.coordinator_fees << fee + end + end + + let!(:another_coordinator_fee) do + create(:enterprise_fee, enterprise: another_order_cycle.coordinator).tap do |fee| + another_order_cycle.coordinator_fees << fee + end + end + + it "includes enterprise fee in allowed order cycle" do + expect(authorizer.allowed_enterprise_fees).to include(coordinator_fee) + end + + it "does not include enterprise fee in order cycle that is not allowed" do + expect(authorizer.allowed_enterprise_fees).not_to include(another_coordinator_fee) + end + end + + context "when enterprise fee for incoming exchange" do + let!(:exchange_fee) do + create(:enterprise_fee, enterprise: incoming_exchange.sender).tap do |fee| + incoming_exchange.enterprise_fees << fee + end + end + + let!(:another_exchange_fee) do + create(:enterprise_fee, enterprise: another_incoming_exchange.sender).tap do |fee| + another_incoming_exchange.enterprise_fees << fee + end + end + + it "includes enterprise fee in allowed order cycle" do + expect(authorizer.allowed_enterprise_fees).to include(exchange_fee) + end + + it "does not include enterprise fee in order cycle that is not allowed" do + expect(authorizer.allowed_enterprise_fees).not_to include(another_exchange_fee) + end + end + + context "when enterprise fee for outgoing exchange" do + let!(:exchange_fee) do + create(:enterprise_fee, enterprise: outgoing_exchange.receiver).tap do |fee| + outgoing_exchange.enterprise_fees << fee + end + end + + let!(:another_exchange_fee) do + create(:enterprise_fee, enterprise: another_outgoing_exchange.receiver).tap do |fee| + another_outgoing_exchange.enterprise_fees << fee + end + end + + it "includes enterprise fee in allowed order cycle" do + expect(authorizer.allowed_enterprise_fees).to include(exchange_fee) + end + + it "does not include enterprise fee in order cycle that is not allowed" do + expect(authorizer.allowed_enterprise_fees).not_to include(another_exchange_fee) + end + end + end + + describe "allowed shipping methods" do + it "includes shipping methods of distributors in allowed order cycle" do + shipping_method = create(:shipping_method, distributors: [outgoing_exchange.receiver]) + expect(authorizer.allowed_shipping_methods).to include(shipping_method) + end + + it "does not include shipping methods of suppliers in allowed order cycle" do + shipping_method = create(:shipping_method, distributors: [incoming_exchange.sender]) + expect(authorizer.allowed_shipping_methods).not_to include(shipping_method) + end + + it "does not include shipping methods of coordinator of allowed order cycle" do + shipping_method = create(:shipping_method, distributors: [order_cycle.coordinator]) + expect(authorizer.allowed_shipping_methods).not_to include(shipping_method) + end + + it "does not include shipping methods of distributors in order cycle that is not allowed" do + shipping_method = create(:shipping_method, + distributors: [another_outgoing_exchange.receiver]) + expect(authorizer.allowed_shipping_methods).not_to include(shipping_method) + end + end + + describe "allowed payment methods" do + it "includes payment methods of distributors in allowed order cycle" do + payment_method = create(:payment_method, distributors: [outgoing_exchange.receiver]) + expect(authorizer.allowed_payment_methods).to include(payment_method) + end + + it "does not include payment methods of suppliers in allowed order cycle" do + payment_method = create(:payment_method, distributors: [incoming_exchange.sender]) + expect(authorizer.allowed_payment_methods).not_to include(payment_method) + end + + it "does not include payment methods of coordinator of allowed order cycle" do + payment_method = create(:payment_method, distributors: [order_cycle.coordinator]) + expect(authorizer.allowed_payment_methods).not_to include(payment_method) + end + + it "does not include payment methods of distributors in order cycle that is not allowed" do + payment_method = create(:payment_method, distributors: [another_outgoing_exchange.receiver]) + expect(authorizer.allowed_payment_methods).not_to include(payment_method) + end + end + end +end diff --git a/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/renderers/csv_renderer_spec.rb b/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/renderers/csv_renderer_spec.rb new file mode 100644 index 0000000000..d265de4933 --- /dev/null +++ b/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/renderers/csv_renderer_spec.rb @@ -0,0 +1,89 @@ +require "spec_helper" + +describe OrderManagement::Reports::EnterpriseFeeSummary::Renderers::CsvRenderer do + let(:report_klass) { OrderManagement::Reports::EnterpriseFeeSummary } + + let!(:permissions) { report_klass::Permissions.new(current_user) } + let!(:parameters) { report_klass::Parameters.new } + let!(:service) { report_klass::ReportService.new(permissions, parameters) } + let!(:renderer) { described_class.new(service) } + + # Context which will be passed to the renderer. The response object is not automatically prepared, + # so this has to be assigned explicitly. + let!(:response) { ActionController::TestResponse.new } + let!(:controller) do + ActionController::Base.new.tap do |controller_mock| + controller_mock.instance_variable_set(:@_response, response) + end + end + + let!(:enterprise_fee_type_totals) do + [ + report_klass::ReportData::EnterpriseFeeTypeTotal.new( + fee_type: "Fee Type A", + enterprise_name: "Enterprise A", + fee_name: "Fee A", + customer_name: "Custoemr A", + fee_placement: "Fee Placement A", + fee_calculated_on_transfer_through_name: "Transfer Enterprise A", + tax_category_name: "Tax Category A", + total_amount: "1.00" + ), + report_klass::ReportData::EnterpriseFeeTypeTotal.new( + fee_type: "Fee Type B", + enterprise_name: "Enterprise B", + fee_name: "Fee C", + customer_name: "Custoemr D", + fee_placement: "Fee Placement E", + fee_calculated_on_transfer_through_name: "Transfer Enterprise F", + tax_category_name: "Tax Category G", + total_amount: "2.00" + ) + ] + end + + let(:current_user) { nil } + + before do + allow(service).to receive(:list) { enterprise_fee_type_totals } + end + + it "generates CSV header" do + renderer.render(controller) + result = response.body + csv = CSV.parse(result) + header_row = csv[0] + + # Test all header cells have values + expect(header_row.length).to eq(8) + expect(header_row.all?(&:present?)).to be_truthy + end + + it "generates CSV data rows" do + renderer.render(controller) + result = response.body + csv = CSV.parse(result, headers: true) + + expect(csv.length).to eq(2) + + # Test random cells + expect(csv[0][i18n_translate("header.fee_type")]).to eq("Fee Type A") + expect(csv[0][i18n_translate("header.total_amount")]).to eq("1.00") + expect(csv[1][i18n_translate("header.total_amount")]).to eq("2.00") + end + + it "generates filename correctly" do + Timecop.freeze(Time.zone.local(2018, 10, 9, 7, 30, 0)) do + filename = renderer.__send__(:filename) + expect(filename).to eq("enterprise_fee_summary_20181009.csv") + end + end + + def i18n_translate(key) + I18n.t(key, scope: i18n_scope) + end + + def i18n_scope + "order_management.reports.enterprise_fee_summary.formats.csv" + end +end diff --git a/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/renderers/html_renderer_spec.rb b/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/renderers/html_renderer_spec.rb new file mode 100644 index 0000000000..5b9c62b03c --- /dev/null +++ b/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/renderers/html_renderer_spec.rb @@ -0,0 +1,70 @@ +require "spec_helper" + +describe OrderManagement::Reports::EnterpriseFeeSummary::Renderers::HtmlRenderer do + let(:report_klass) { OrderManagement::Reports::EnterpriseFeeSummary } + + let!(:permissions) { report_klass::Permissions.new(current_user) } + let!(:parameters) { report_klass::Parameters.new } + let!(:controller) { Spree::Admin::Reports::EnterpriseFeeSummariesController.new } + let!(:service) { report_klass::ReportService.new(permissions, parameters) } + let!(:renderer) { described_class.new(service) } + + let!(:enterprise_fee_type_totals) do + [ + report_klass::ReportData::EnterpriseFeeTypeTotal.new( + fee_type: "Fee Type A", + enterprise_name: "Enterprise A", + fee_name: "Fee A", + customer_name: "Custoemr A", + fee_placement: "Fee Placement A", + fee_calculated_on_transfer_through_name: "Transfer Enterprise A", + tax_category_name: "Tax Category A", + total_amount: "1.00" + ), + report_klass::ReportData::EnterpriseFeeTypeTotal.new( + fee_type: "Fee Type B", + enterprise_name: "Enterprise B", + fee_name: "Fee C", + customer_name: "Custoemr D", + fee_placement: "Fee Placement E", + fee_calculated_on_transfer_through_name: "Transfer Enterprise F", + tax_category_name: "Tax Category G", + total_amount: "2.00" + ) + ] + end + + let(:current_user) { nil } + + before do + allow(service).to receive(:list) { enterprise_fee_type_totals } + end + + it "generates header values" do + header_row = renderer.header + + # Test all header cells have values + expect(header_row.length).to eq(8) + expect(header_row.all?(&:present?)).to be_truthy + end + + it "generates data rows" do + header_row = renderer.header + result = renderer.data_rows + + expect(result.length).to eq(2) + + # Test random cells + expect(result[0][header_row.index(i18n_translate("header.fee_type"))]).to eq("Fee Type A") + expect(result[0][header_row.index(i18n_translate("header.total_amount"))]).to eq("1.00") + expect(result[1][header_row.index(i18n_translate("header.total_amount"))]).to eq("2.00") + end + + def i18n_translate(key) + I18n.t(key, scope: i18n_scope) + end + + def i18n_scope + "order_management.reports.enterprise_fee_summary.formats.csv" + end +end diff --git a/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/report_data/enterprise_fee_type_total_spec.rb b/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/report_data/enterprise_fee_type_total_spec.rb new file mode 100644 index 0000000000..9a30660692 --- /dev/null +++ b/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/report_data/enterprise_fee_type_total_spec.rb @@ -0,0 +1,46 @@ +require "spec_helper" + +describe OrderManagement::Reports::EnterpriseFeeSummary::ReportData::EnterpriseFeeTypeTotal do + it "sorts instances according to their attributes" do + instance_a = described_class.new( + fee_type: "sales", + enterprise_name: "Enterprise A", + fee_name: "A Sales", + customer_name: "Customer A", + fee_placement: "Incoming", + fee_calculated_on_transfer_through_name: "Transfer Enterprise B", + tax_category_name: "Sales 4%", + total_amount: "12.00" + ) + + instance_b = described_class.new( + fee_type: "sales", + enterprise_name: "Enterprise A", + fee_name: "B Sales", + customer_name: "Customer A", + fee_placement: "Incoming", + fee_calculated_on_transfer_through_name: "Transfer Enterprise B", + tax_category_name: "Sales 4%", + total_amount: "12.00" + ) + + instance_c = described_class.new( + fee_type: "admin", + enterprise_name: "Enterprise A", + fee_name: "C Admin", + customer_name: "Customer B", + fee_placement: "Incoming", + fee_calculated_on_transfer_through_name: nil, + tax_category_name: "Sales 6%", + total_amount: "12.00" + ) + + list = [ + instance_a, + instance_b, + instance_c + ] + + expect(list.sort).to eq([instance_c, instance_a, instance_b]) + end +end diff --git a/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/report_service_spec.rb b/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/report_service_spec.rb new file mode 100644 index 0000000000..83a3f77576 --- /dev/null +++ b/engines/order_management/spec/services/order_management/reports/enterprise_fee_summary/report_service_spec.rb @@ -0,0 +1,536 @@ +require "spec_helper" + +describe OrderManagement::Reports::EnterpriseFeeSummary::ReportService do + let(:report_klass) { OrderManagement::Reports::EnterpriseFeeSummary } + + # Basic data. + let!(:shipping_method) do + create(:shipping_method, :per_item, amount: 1, name: "Sample Shipping Method") + end + + let!(:payment_method) do + create(:payment_method, :per_item, amount: 2, name: "Sample Payment Method") + end + + # Create enterprises. + let!(:distributor) do + create(:distributor_enterprise, name: "Sample Distributor").tap do |enterprise| + payment_method.distributors << enterprise + shipping_method.distributors << enterprise + end + end + let!(:producer) { create(:supplier_enterprise, name: "Sample Producer") } + let!(:coordinator) { create(:enterprise, name: "Sample Coordinator") } + + # Add some fee noise. + let!(:other_distributor_fee) { create(:enterprise_fee, :per_item, enterprise: distributor) } + let!(:other_producer_fee) { create(:enterprise_fee, :per_item, enterprise: producer) } + let!(:other_coordinator_fee) { create(:enterprise_fee, :per_item, enterprise: coordinator) } + + # Set up other requirements for ordering. + let!(:order_cycle) { create(:simple_order_cycle, coordinator: coordinator) } + let!(:product) { create(:product, tax_category: product_tax_category) } + let!(:product_tax_category) { create(:tax_category, name: "Sample Product Tax") } + let!(:variant) { prepare_variant } + + # Create customers. + let!(:customer) { create(:customer, name: "Sample Customer") } + let!(:another_customer) { create(:customer, name: "Another Customer") } + + # Setup up permissions and report. + let!(:current_user) { create(:admin_user) } + + let(:permissions) { report_klass::Permissions.new(current_user) } + let(:parameters) { report_klass::Parameters.new } + let(:service) { described_class.new(permissions, parameters) } + + describe "grouping and sorting of entries" do + let!(:order_cycle) do + create(:simple_order_cycle, coordinator: coordinator, coordinator_fees: order_cycle_fees) + end + + let!(:variant) do + prepare_variant(incoming_exchange_fees: variant_incoming_exchange_fees, + outgoing_exchange_fees: variant_outgoing_exchange_fees) + end + + let!(:order_cycle_fees) do + [ + create(:enterprise_fee, :per_item, name: "Coordinator Fee 1", enterprise: coordinator, + fee_type: "admin", amount: 512.0, + tax_category: coordinator_tax_category), + create(:enterprise_fee, :per_item, name: "Coordinator Fee 2", enterprise: coordinator, + fee_type: "sales", amount: 1024.0, + inherits_tax_category: true) + ] + end + let!(:coordinator_tax_category) { create(:tax_category, name: "Sample Coordinator Tax") } + + let!(:variant_incoming_exchange_fees) do + [ + create(:enterprise_fee, :per_item, name: "Producer Fee 1", enterprise: producer, + fee_type: "sales", amount: 64.0, + tax_category: producer_tax_category), + create(:enterprise_fee, :per_item, name: "Producer Fee 2", enterprise: producer, + fee_type: "sales", amount: 128.0, + inherits_tax_category: true) + ] + end + let!(:producer_tax_category) { create(:tax_category, name: "Sample Producer Tax") } + + let!(:variant_outgoing_exchange_fees) do + [ + create(:enterprise_fee, :per_item, name: "Distributor Fee 1", enterprise: distributor, + fee_type: "admin", amount: 4.0, + tax_category: distributor_tax_category), + create(:enterprise_fee, :per_item, name: "Distributor Fee 2", enterprise: distributor, + fee_type: "sales", amount: 8.0, + inherits_tax_category: true) + ] + end + let!(:distributor_tax_category) { create(:tax_category, name: "Sample Distributor Tax") } + + let!(:customer_order) { prepare_order(customer: customer) } + let!(:customer_incomplete_order) { prepare_incomplete_order(customer: customer) } + let!(:second_customer_order) { prepare_order(customer: customer) } + let!(:other_customer_order) { prepare_order(customer: another_customer) } + + it "groups and sorts entries correctly" do + totals = service.list + + expect(totals.length).to eq(16) + + # Data is sorted by the following, in order: + # * fee_type + # * enterprise_name + # * fee_name + # * customer_name + # * fee_placement + # * fee_calculated_on_transfer_through_name + # * tax_category_name + # * total_amount + + expected_result = [ + ["Admin", "Sample Coordinator", "Coordinator Fee 1", "Another Customer", + "Coordinator", "All", "Sample Coordinator Tax", "512.00"], + ["Admin", "Sample Coordinator", "Coordinator Fee 1", "Sample Customer", + "Coordinator", "All", "Sample Coordinator Tax", "1024.00"], + ["Admin", "Sample Distributor", "Distributor Fee 1", "Another Customer", + "Outgoing", "Sample Distributor", "Sample Distributor Tax", "4.00"], + ["Admin", "Sample Distributor", "Distributor Fee 1", "Sample Customer", + "Outgoing", "Sample Distributor", "Sample Distributor Tax", "8.00"], + ["Payment Transaction", "Sample Distributor", "Sample Payment Method", "Another Customer", + nil, nil, nil, "2.00"], + ["Payment Transaction", "Sample Distributor", "Sample Payment Method", "Sample Customer", + nil, nil, nil, "4.00"], + ["Sales", "Sample Coordinator", "Coordinator Fee 2", "Another Customer", + "Coordinator", "All", "Sample Product Tax", "1024.00"], + ["Sales", "Sample Coordinator", "Coordinator Fee 2", "Sample Customer", + "Coordinator", "All", "Sample Product Tax", "2048.00"], + ["Sales", "Sample Distributor", "Distributor Fee 2", "Another Customer", + "Outgoing", "Sample Distributor", "Sample Product Tax", "8.00"], + ["Sales", "Sample Distributor", "Distributor Fee 2", "Sample Customer", + "Outgoing", "Sample Distributor", "Sample Product Tax", "16.00"], + ["Sales", "Sample Producer", "Producer Fee 1", "Another Customer", + "Incoming", "Sample Producer", "Sample Producer Tax", "64.00"], + ["Sales", "Sample Producer", "Producer Fee 1", "Sample Customer", + "Incoming", "Sample Producer", "Sample Producer Tax", "128.00"], + ["Sales", "Sample Producer", "Producer Fee 2", "Another Customer", + "Incoming", "Sample Producer", "Sample Product Tax", "128.00"], + ["Sales", "Sample Producer", "Producer Fee 2", "Sample Customer", + "Incoming", "Sample Producer", "Sample Product Tax", "256.00"], + ["Shipment", "Sample Distributor", "Sample Shipping Method", "Another Customer", + nil, nil, "Platform Rate", "1.00"], + ["Shipment", "Sample Distributor", "Sample Shipping Method", "Sample Customer", + nil, nil, "Platform Rate", "2.00"] + ] + + expected_result.each_with_index do |expected_attributes, row_index| + expect_total_attributes(totals[row_index], expected_attributes) + end + end + end + + describe "handling of more complex cases" do + context "with non-sender fee for incoming exchange and non-receiver fee for outgoing" do + let!(:variant) do + prepare_variant(incoming_exchange_fees: variant_incoming_exchange_fees, + outgoing_exchange_fees: variant_outgoing_exchange_fees) + end + let!(:variant_incoming_exchange_fees) { [coordinator_fee, distributor_fee] } + let!(:variant_outgoing_exchange_fees) { [producer_fee, coordinator_fee] } + + let!(:producer_fee) do + tax_category = create(:tax_category, name: "Sample Producer Tax") + create(:enterprise_fee, :per_item, name: "Sample Producer Fee", enterprise: producer, + fee_type: "sales", amount: 64.0, + tax_category: tax_category) + end + let!(:coordinator_fee) do + tax_category = create(:tax_category, name: "Sample Coordinator Tax") + create(:enterprise_fee, :per_item, name: "Sample Coordinator Fee", enterprise: coordinator, + fee_type: "admin", amount: 512.0, + tax_category: tax_category) + end + let!(:distributor_fee) do + tax_category = create(:tax_category, name: "Sample Distributor Tax") + create(:enterprise_fee, :per_item, name: "Sample Distributor Fee", enterprise: distributor, + fee_type: "admin", amount: 4.0, + tax_category: tax_category) + end + + let!(:customer_order) { prepare_order(customer: customer) } + + it "fetches data correctly" do + totals = service.list + + expect(totals.length).to eq(6) + + expected_result = [ + ["Admin", "Sample Coordinator", "Sample Coordinator Fee", "Sample Customer", + "Incoming", "Sample Producer", "Sample Coordinator Tax", "512.00"], + ["Admin", "Sample Coordinator", "Sample Coordinator Fee", "Sample Customer", + "Outgoing", "Sample Distributor", "Sample Coordinator Tax", "512.00"], + ["Admin", "Sample Distributor", "Sample Distributor Fee", "Sample Customer", + "Incoming", "Sample Producer", "Sample Distributor Tax", "4.00"], + ["Payment Transaction", "Sample Distributor", "Sample Payment Method", "Sample Customer", + nil, nil, nil, "2.00"], + ["Sales", "Sample Producer", "Sample Producer Fee", "Sample Customer", + "Outgoing", "Sample Distributor", "Sample Producer Tax", "64.00"], + ["Shipment", "Sample Distributor", "Sample Shipping Method", "Sample Customer", + nil, nil, "Platform Rate", "1.00"] + ] + + expected_result.each_with_index do |expected_attributes, row_index| + expect_total_attributes(totals[row_index], expected_attributes) + end + end + end + end + + describe "filtering results based on permissions" do + let!(:distributor_a) do + create(:distributor_enterprise, name: "Distributor A", payment_methods: [payment_method], + shipping_methods: [shipping_method]) + end + let!(:distributor_b) do + create(:distributor_enterprise, name: "Distributor B", payment_methods: [payment_method], + shipping_methods: [shipping_method]) + end + + let!(:order_cycle_a) { create(:simple_order_cycle, coordinator: coordinator) } + let!(:order_cycle_b) { create(:simple_order_cycle, coordinator: coordinator) } + + let!(:variant_a) { prepare_variant(distributor: distributor_a, order_cycle: order_cycle_a) } + let!(:variant_b) { prepare_variant(distributor: distributor_b, order_cycle: order_cycle_b) } + + let!(:order_a) { prepare_order(order_cycle: order_cycle_a, distributor: distributor_a) } + let!(:order_b) { prepare_order(order_cycle: order_cycle_b, distributor: distributor_b) } + + context "when admin" do + let!(:current_user) { create(:admin_user) } + + it "includes all order cycles" do + totals = service.list + + expect_total_matches(totals, 2, fee_type: "Shipment") + expect_total_matches(totals, 1, fee_type: "Shipment", enterprise_name: "Distributor A") + expect_total_matches(totals, 1, fee_type: "Shipment", enterprise_name: "Distributor B") + end + end + + context "when enterprise owner for distributor" do + let!(:current_user) { distributor_a.owner } + + it "does not include unrelated order cycles" do + totals = service.list + + expect_total_matches(totals, 1, fee_type: "Shipment") + expect_total_matches(totals, 1, fee_type: "Shipment", enterprise_name: "Distributor A") + end + end + end + + describe "filters entries correctly" do + let(:parameters) { report_klass::Parameters.new(parameters_attributes) } + + context "filtering by completion date" do + let(:timestamp) { Time.zone.local(2018, 1, 5, 14, 30, 5) } + + let!(:customer_a) { create(:customer, name: "Customer A") } + let!(:customer_b) { create(:customer, name: "Customer B") } + let!(:customer_c) { create(:customer, name: "Customer C") } + + let!(:order_placed_before_timestamp) do + prepare_order(customer: customer_a).tap do |order| + order.update_column(:completed_at, timestamp - 1.second) + end + end + + let!(:order_placed_during_timestamp) do + prepare_order(customer: customer_b).tap do |order| + order.update_column(:completed_at, timestamp) + end + end + + let!(:order_placed_after_timestamp) do + prepare_order(customer: customer_c).tap do |order| + order.update_column(:completed_at, timestamp + 1.second) + end + end + + context "on or after start_at" do + let(:parameters_attributes) { { start_at: timestamp } } + + it "filters entries" do + totals = service.list + + expect_total_matches(totals, 0, fee_type: "Shipment", customer_name: "Customer A") + expect_total_matches(totals, 1, fee_type: "Shipment", customer_name: "Customer B") + expect_total_matches(totals, 1, fee_type: "Shipment", customer_name: "Customer C") + end + end + + context "on or before end_at" do + let(:parameters_attributes) { { end_at: timestamp } } + + it "filters entries" do + totals = service.list + + expect_total_matches(totals, 1, fee_type: "Shipment", customer_name: "Customer A") + expect_total_matches(totals, 1, fee_type: "Shipment", customer_name: "Customer B") + expect_total_matches(totals, 0, fee_type: "Shipment", customer_name: "Customer C") + end + end + end + + describe "for specified shops" do + let!(:distributor_a) do + create(:distributor_enterprise, name: "Distributor A", payment_methods: [payment_method], + shipping_methods: [shipping_method]) + end + let!(:distributor_b) do + create(:distributor_enterprise, name: "Distributor B", payment_methods: [payment_method], + shipping_methods: [shipping_method]) + end + let!(:distributor_c) do + create(:distributor_enterprise, name: "Distributor C", payment_methods: [payment_method], + shipping_methods: [shipping_method]) + end + + let!(:order_a) { prepare_order(distributor: distributor_a) } + let!(:order_b) { prepare_order(distributor: distributor_b) } + let!(:order_c) { prepare_order(distributor: distributor_c) } + + let(:parameters_attributes) { { distributor_ids: [distributor_a.id, distributor_b.id] } } + + it "filters entries" do + totals = service.list + + expect_total_matches(totals, 1, fee_type: "Shipment", enterprise_name: "Distributor A") + expect_total_matches(totals, 1, fee_type: "Shipment", enterprise_name: "Distributor B") + expect_total_matches(totals, 0, fee_type: "Shipment", enterprise_name: "Distributor C") + end + end + + describe "for specified suppliers" do + let!(:producer_a) { create(:supplier_enterprise, name: "Producer A") } + let!(:producer_b) { create(:supplier_enterprise, name: "Producer B") } + let!(:producer_c) { create(:supplier_enterprise, name: "Producer C") } + + let!(:fee_a) { create(:enterprise_fee, name: "Fee A", enterprise: producer_a) } + let!(:fee_b) { create(:enterprise_fee, name: "Fee B", enterprise: producer_b) } + let!(:fee_c) { create(:enterprise_fee, name: "Fee C", enterprise: producer_c) } + + let!(:product_a) { create(:product, supplier: producer_a) } + let!(:product_b) { create(:product, supplier: producer_b) } + let!(:product_c) { create(:product, supplier: producer_c) } + + let!(:variant_a) do + prepare_variant(product: product_a, producer: producer_a, incoming_exchange_fees: [fee_a]) + end + let!(:variant_b) do + prepare_variant(product: product_b, producer: producer_b, incoming_exchange_fees: [fee_b]) + end + let!(:variant_c) do + prepare_variant(product: product_c, producer: producer_c, incoming_exchange_fees: [fee_c]) + end + + let!(:order_a) { prepare_order(variant: variant_a) } + let!(:order_b) { prepare_order(variant: variant_b) } + let!(:order_c) { prepare_order(variant: variant_c) } + + let(:parameters_attributes) { { producer_ids: [producer_a.id, producer_b.id] } } + + it "filters entries" do + totals = service.list + + expect_total_matches(totals, 1, fee_name: "Fee A", enterprise_name: "Producer A") + expect_total_matches(totals, 1, fee_name: "Fee B", enterprise_name: "Producer B") + expect_total_matches(totals, 0, fee_name: "Fee C", enterprise_name: "Producer C") + end + end + + describe "for specified order cycles" do + let!(:distributor_a) do + create(:distributor_enterprise, name: "Distributor A", payment_methods: [payment_method], + shipping_methods: [shipping_method]) + end + let!(:distributor_b) do + create(:distributor_enterprise, name: "Distributor B", payment_methods: [payment_method], + shipping_methods: [shipping_method]) + end + let!(:distributor_c) do + create(:distributor_enterprise, name: "Distributor C", payment_methods: [payment_method], + shipping_methods: [shipping_method]) + end + + let!(:order_cycle_a) { create(:simple_order_cycle, coordinator: coordinator) } + let!(:order_cycle_b) { create(:simple_order_cycle, coordinator: coordinator) } + let!(:order_cycle_c) { create(:simple_order_cycle, coordinator: coordinator) } + + let!(:variant_a) { prepare_variant(distributor: distributor_a, order_cycle: order_cycle_a) } + let!(:variant_b) { prepare_variant(distributor: distributor_b, order_cycle: order_cycle_b) } + let!(:variant_c) { prepare_variant(distributor: distributor_c, order_cycle: order_cycle_c) } + + let!(:order_a) { prepare_order(order_cycle: order_cycle_a, distributor: distributor_a) } + let!(:order_b) { prepare_order(order_cycle: order_cycle_b, distributor: distributor_b) } + let!(:order_c) { prepare_order(order_cycle: order_cycle_c, distributor: distributor_c) } + + let(:parameters_attributes) { { order_cycle_ids: [order_cycle_a.id, order_cycle_b.id] } } + + it "filters entries" do + totals = service.list + + expect_total_matches(totals, 1, fee_type: "Shipment", enterprise_name: "Distributor A") + expect_total_matches(totals, 1, fee_type: "Shipment", enterprise_name: "Distributor B") + expect_total_matches(totals, 0, fee_type: "Shipment", enterprise_name: "Distributor C") + end + end + + describe "for specified enterprise fees" do + let!(:fee_a) { create(:enterprise_fee, name: "Fee A", enterprise: distributor) } + let!(:fee_b) { create(:enterprise_fee, name: "Fee B", enterprise: distributor) } + let!(:fee_c) { create(:enterprise_fee, name: "Fee C", enterprise: distributor) } + + let!(:variant) { prepare_variant(outgoing_exchange_fees: variant_outgoing_exchange_fees) } + let!(:variant_outgoing_exchange_fees) { [fee_a, fee_b, fee_c] } + + let!(:order) { prepare_order(variant: variant) } + + let(:parameters_attributes) { { enterprise_fee_ids: [fee_a.id, fee_b.id] } } + + it "filters entries" do + totals = service.list + + expect_total_matches(totals, 1, fee_name: "Fee A") + expect_total_matches(totals, 1, fee_name: "Fee B") + expect_total_matches(totals, 0, fee_name: "Fee C") + end + end + + describe "for specified shipping methods" do + let!(:shipping_method_a) do + create(:shipping_method, name: "Shipping A", distributors: [distributor]) + end + let!(:shipping_method_b) do + create(:shipping_method, name: "Shipping B", distributors: [distributor]) + end + let!(:shipping_method_c) do + create(:shipping_method, name: "Shipping C", distributors: [distributor]) + end + + let!(:order_a) { prepare_order(shipping_method: shipping_method_a) } + let!(:order_b) { prepare_order(shipping_method: shipping_method_b) } + let!(:order_c) { prepare_order(shipping_method: shipping_method_c) } + + let(:parameters_attributes) do + { shipping_method_ids: [shipping_method_a.id, shipping_method_b.id] } + end + + it "filters entries" do + totals = service.list + + expect_total_matches(totals, 1, fee_name: "Shipping A") + expect_total_matches(totals, 1, fee_name: "Shipping B") + expect_total_matches(totals, 0, fee_name: "Shipping C") + end + end + + describe "for specified payment methods" do + let!(:payment_method_a) do + create(:payment_method, name: "Payment A", distributors: [distributor]) + end + let!(:payment_method_b) do + create(:payment_method, name: "Payment B", distributors: [distributor]) + end + let!(:payment_method_c) do + create(:payment_method, name: "Payment C", distributors: [distributor]) + end + + let!(:order_a) { prepare_order(payment_method: payment_method_a) } + let!(:order_b) { prepare_order(payment_method: payment_method_b) } + let!(:order_c) { prepare_order(payment_method: payment_method_c) } + + let(:parameters_attributes) do + { payment_method_ids: [payment_method_a.id, payment_method_b.id] } + end + + it "filters entries" do + totals = service.list + + expect_total_matches(totals, 1, fee_name: "Payment A") + expect_total_matches(totals, 1, fee_name: "Payment B") + expect_total_matches(totals, 0, fee_name: "Payment C") + end + end + end + + # Helper methods for example group + + def expect_total_attributes(total, expected_attribute_list) + actual_attribute_list = [total.fee_type, total.enterprise_name, total.fee_name, + total.customer_name, total.fee_placement, + total.fee_calculated_on_transfer_through_name, total.tax_category_name, + total.total_amount] + expect(actual_attribute_list).to eq(expected_attribute_list) + end + + def expect_total_matches(totals, count, attributes) + expect(count_totals(totals, attributes)).to eq(count) + end + + def default_order_options + { customer: customer, distributor: distributor, order_cycle: order_cycle, + shipping_method: shipping_method, variant: variant } + end + + def prepare_incomplete_order(options = {}) + target_options = default_order_options.merge(options) + create(:order, :with_line_item, target_options) + end + + def prepare_order(options = {}) + factory_trait_options = { payment_method: payment_method } + target_options = default_order_options.merge(factory_trait_options).merge(options) + create(:order, :with_line_item, :completed, target_options) + end + + def default_variant_options + { product: product, producer: producer, is_master: false, coordinator: coordinator, + distributor: distributor, order_cycle: order_cycle } + end + + def prepare_variant(options = {}) + target_options = default_variant_options.merge(options) + create(:variant, :with_order_cycle, target_options) + end + + def count_totals(totals, attributes) + totals.count do |data| + attributes.all? do |attribute_name, attribute_value| + data.public_send(attribute_name) == attribute_value + end + end + end +end diff --git a/engines/order_management/spec/spec_helper.rb b/engines/order_management/spec/spec_helper.rb new file mode 100644 index 0000000000..1be4512650 --- /dev/null +++ b/engines/order_management/spec/spec_helper.rb @@ -0,0 +1,10 @@ +ENV["RAILS_ENV"] = "test" + +require "order_management" +require "../../spec/spec_helper" + +# Require factories in Spree and main application. +require 'spree/testing_support/factories' +require '../../spec/factories' + +Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } diff --git a/lib/open_food_network/reports/list.rb b/lib/open_food_network/reports/list.rb new file mode 100644 index 0000000000..e4425ba936 --- /dev/null +++ b/lib/open_food_network/reports/list.rb @@ -0,0 +1,79 @@ +module OpenFoodNetwork + module Reports + class List + def self.all + new.all + end + + def all + { + orders_and_fulfillment: orders_and_fulfillment_report_types, + products_and_inventory: products_and_inventory_report_types, + customers: customers_report_types, + enterprise_fee_summary: enterprise_fee_summary_report_types, + order_cycle_management: order_cycle_management_report_types, + sales_tax: sales_tax_report_types, + packing: packing_report_types + } + end + + protected + + def orders_and_fulfillment_report_types + [ + [i18n_translate("supplier_totals"), :order_cycle_supplier_totals], + [i18n_translate("supplier_totals_by_distributor"), + :order_cycle_supplier_totals_by_distributor], + [i18n_translate("totals_by_supplier"), :order_cycle_distributor_totals_by_supplier], + [i18n_translate("customer_totals"), :order_cycle_customer_totals] + ] + end + + def products_and_inventory_report_types + [ + [i18n_translate("all_products"), :all_products], + [i18n_translate("inventory"), :inventory], + [i18n_translate("lettuce_share"), :lettuce_share] + ] + end + + def customers_report_types + [ + [i18n_translate("mailing_list"), :mailing_list], + [i18n_translate("addresses"), :addresses] + ] + end + + def enterprise_fee_summary_report_types + [ + [i18n_translate("enterprise_fee_summary"), :enterprise_fee_summary] + ] + end + + def order_cycle_management_report_types + [ + [i18n_translate("payment_methods"), :payment_methods], + [i18n_translate("delivery"), :delivery] + ] + end + + def sales_tax_report_types + [ + [i18n_translate("tax_types"), :tax_types], + [i18n_translate("tax_rates"), :tax_rates] + ] + end + + def packing_report_types + [ + [i18n_translate("pack_by_customer"), :pack_by_customer], + [i18n_translate("pack_by_supplier"), :pack_by_supplier] + ] + end + + def i18n_translate(key) + I18n.t(key, scope: "admin.reports") + end + end + end +end diff --git a/lib/open_food_network/scope_variants_for_search.rb b/lib/open_food_network/scope_variants_for_search.rb index 9befef5304..24b110fb6e 100644 --- a/lib/open_food_network/scope_variants_for_search.rb +++ b/lib/open_food_network/scope_variants_for_search.rb @@ -33,6 +33,10 @@ module OpenFoodNetwork Spree::Variant.where(is_master: false).ransack(search_params.merge(m: 'or')).result end + def distributor + Enterprise.find params[:distributor_id] + end + def scope_to_schedule @variants = @variants.in_schedule(params[:schedule_id]) end @@ -42,12 +46,29 @@ module OpenFoodNetwork end def scope_to_distributor - distributor = Enterprise.find params[:distributor_id] + if params[:eligible_for_subscriptions] + scope_to_eligible_for_subscriptions_in_distributor + else + scope_to_available_for_orders_in_distributor + end + end + + def scope_to_available_for_orders_in_distributor @variants = @variants.in_distributor(distributor) + scope_variants_to_distributor(@variants, distributor) + end + + def scope_to_eligible_for_subscriptions_in_distributor + eligible_variants_scope = SubscriptionVariantsService.eligible_variants(distributor) + @variants = @variants.merge(eligible_variants_scope) + scope_variants_to_distributor(@variants, distributor) + end + + def scope_variants_to_distributor(variants, distributor) scoper = OpenFoodNetwork::ScopeVariantToHub.new(distributor) # Perform scoping after all filtering is done. # Filtering could be a problem on scoped variants. - @variants.each { |v| scoper.scope(v) } + variants.each { |v| scoper.scope(v) } end end end diff --git a/public/inventory_template.csv b/public/inventory_template.csv index d52cecded6..dc2f10e3be 100644 --- a/public/inventory_template.csv +++ b/public/inventory_template.csv @@ -1 +1 @@ -producer,distributor,name,display_name,units,unit_type,price,on_hand +producer,distributor,name,display_name,variant_unit_name,sku,units,unit_type,price,on_hand,on_demand diff --git a/spec/controllers/admin/subscription_line_items_controller_spec.rb b/spec/controllers/admin/subscription_line_items_controller_spec.rb index b2f3a0f432..2b42a20df7 100644 --- a/spec/controllers/admin/subscription_line_items_controller_spec.rb +++ b/spec/controllers/admin/subscription_line_items_controller_spec.rb @@ -10,9 +10,9 @@ describe Admin::SubscriptionLineItemsController, type: :controller do let(:unmanaged_shop) { create(:enterprise) } let!(:product) { create(:product) } let!(:variant) { create(:variant, product: product, unit_value: '100', price: 15.00, option_values: []) } + let!(:outgoing_exchange) { order_cycle.exchanges.create(sender: shop, receiver: shop, variants: [variant], enterprise_fees: [enterprise_fee]) } let!(:enterprise_fee) { create(:enterprise_fee, amount: 3.50) } let!(:order_cycle) { create(:simple_order_cycle, coordinator: shop, orders_open_at: 2.days.from_now, orders_close_at: 7.days.from_now) } - let!(:outgoing_exchange) { order_cycle.exchanges.create(sender: shop, receiver: shop, variants: [variant], enterprise_fees: [enterprise_fee]) } let!(:schedule) { create(:schedule, order_cycles: [order_cycle]) } let(:unmanaged_schedule) { create(:schedule, order_cycles: [create(:simple_order_cycle, coordinator: unmanaged_shop)]) } @@ -42,6 +42,8 @@ describe Admin::SubscriptionLineItemsController, type: :controller do before { params.merge!(shop_id: shop.id) } context "but the shop doesn't have permission to sell product in question" do + let!(:outgoing_exchange) { } + it "returns an error" do spree_post :build, params json_response = JSON.parse(response.body) diff --git a/spec/controllers/admin/subscriptions_controller_spec.rb b/spec/controllers/admin/subscriptions_controller_spec.rb index 5f83bf0529..9385d453f3 100644 --- a/spec/controllers/admin/subscriptions_controller_spec.rb +++ b/spec/controllers/admin/subscriptions_controller_spec.rb @@ -341,7 +341,7 @@ describe Admin::SubscriptionsController, type: :controller do end context 'with subscription_line_items params' do - let!(:product2) { create(:product, supplier: shop) } + let!(:product2) { create(:product) } let!(:variant2) { create(:variant, product: product2, unit_value: '1000', price: 6.00, option_values: []) } before do diff --git a/spec/controllers/spree/admin/invoices_controller_spec.rb b/spec/controllers/spree/admin/invoices_controller_spec.rb index 8fcba725f5..33ccaaadcc 100644 --- a/spec/controllers/spree/admin/invoices_controller_spec.rb +++ b/spec/controllers/spree/admin/invoices_controller_spec.rb @@ -2,10 +2,11 @@ require 'spec_helper' xdescribe Spree::Admin::InvoicesController, type: :controller do let(:order) { create(:order_with_totals_and_distribution) } - let(:user) { create(:admin_user) } + let(:enterprise_user) { create(:user) } + let!(:enterprise) { create(:enterprise, owner: enterprise_user) } before do - allow(controller).to receive(:spree_current_user) { user } + allow(controller).to receive(:spree_current_user) { enterprise_user } end describe "#create" do diff --git a/spec/controllers/spree/admin/reports/enterprise_fee_summaries_controller_spec.rb b/spec/controllers/spree/admin/reports/enterprise_fee_summaries_controller_spec.rb new file mode 100644 index 0000000000..8019035fa4 --- /dev/null +++ b/spec/controllers/spree/admin/reports/enterprise_fee_summaries_controller_spec.rb @@ -0,0 +1,102 @@ +require "spec_helper" + +describe Spree::Admin::Reports::EnterpriseFeeSummariesController, type: :controller do + let(:report_klass) { OrderManagement::Reports::EnterpriseFeeSummary } + + let!(:distributor) { create(:distributor_enterprise) } + + let(:current_user) { distributor.owner } + + before do + feature_flags = instance_double(FeatureFlags, enterprise_fee_summary_enabled?: true) + allow(FeatureFlags).to receive(:new).with(current_user) { feature_flags } + + allow(controller).to receive(:spree_current_user) { current_user } + end + + describe "#new" do + it "renders the report form" do + get :new + + expect(response).to be_success + expect(response).to render_template(new_template_path) + end + + context "when feature flag is in effect" do + before { allow(FeatureFlags).to receive(:new).with(current_user).and_call_original } + + it "is unauthorized" do + get :new + expect(response).to redirect_to spree.unauthorized_path + end + end + end + + describe "#create" do + context "when the parameters are valid" do + it "sends the generated report in the correct format" do + post :create, report: { start_at: "2018-10-09 07:30:00" }, report_format: "csv" + + expect(response).to be_success + expect(response.body).not_to be_blank + expect(response.header["Content-Type"]).to eq("text/csv") + end + + context "when feature flag is in effect" do + before { allow(FeatureFlags).to receive(:new).with(current_user).and_call_original } + + it "is unauthorized" do + post :create, report: { start_at: "2018-10-09 07:30:00" }, report_format: "csv" + expect(response).to redirect_to spree.unauthorized_path + end + end + end + + context "when the parameters are invalid" do + it "renders the report form with an error" do + post :create, report: { start_at: "invalid date" }, report_format: "csv" + + expect(flash[:error]).to eq(I18n.t("invalid_filter_parameters", scope: i18n_scope)) + expect(response).to render_template(new_template_path) + end + end + + context "when some parameters are now allowed" do + let!(:distributor) { create(:distributor_enterprise) } + let!(:other_distributor) { create(:distributor_enterprise) } + + let(:current_user) { distributor.owner } + + it "renders the report form with an error" do + post :create, report: { distributor_ids: [other_distributor.id] }, report_format: "csv" + + expect(flash[:error]).to eq(report_klass::Authorizer.parameter_not_allowed_error_message) + expect(response).to render_template(new_template_path) + end + end + + describe "filtering results based on permissions" do + let!(:distributor) { create(:distributor_enterprise) } + let!(:other_distributor) { create(:distributor_enterprise) } + + let!(:order_cycle) { create(:simple_order_cycle, coordinator: distributor) } + let!(:other_order_cycle) { create(:simple_order_cycle, coordinator: other_distributor) } + + let(:current_user) { distributor.owner } + + it "applies permissions to report" do + post :create, report: {}, report_format: "csv" + + expect(assigns(:permissions).allowed_order_cycles.to_a).to eq([order_cycle]) + end + end + end + + def i18n_scope + "order_management.reports.enterprise_fee_summary" + end + + def new_template_path + "spree/admin/reports/enterprise_fee_summaries/new" + end +end diff --git a/spec/factories/calculated_adjustment_factory.rb b/spec/factories/calculated_adjustment_factory.rb new file mode 100644 index 0000000000..50ae6f5fce --- /dev/null +++ b/spec/factories/calculated_adjustment_factory.rb @@ -0,0 +1,12 @@ +attach_per_item_trait = proc do + trait :per_item do + transient { amount 1 } + calculator { build(:calculator_per_item, preferred_amount: amount) } + end +end + +FactoryBot.modify do + factory :payment_method, &attach_per_item_trait + factory :shipping_method, &attach_per_item_trait + factory :enterprise_fee, &attach_per_item_trait +end diff --git a/spec/factories/order_factory.rb b/spec/factories/order_factory.rb new file mode 100644 index 0000000000..4b2a33f3d0 --- /dev/null +++ b/spec/factories/order_factory.rb @@ -0,0 +1,27 @@ +FactoryBot.modify do + factory :order do + trait :with_line_item do + transient do + variant { FactoryGirl.create(:variant) } + end + + after(:create) do |order, evaluator| + create(:line_item, order: order, variant: evaluator.variant) + end + end + + trait :completed do + transient do + payment_method { create(:payment_method, distributors: [distributor]) } + end + + after(:create) do |order, evaluator| + order.create_shipment! + create(:payment, state: "checkout", order: order, amount: order.total, + payment_method: evaluator.payment_method) + order.update_distribution_charge! + while !order.completed? do break unless order.next! end + end + end + end +end diff --git a/spec/factories/variant_factory.rb b/spec/factories/variant_factory.rb new file mode 100644 index 0000000000..6eaeb9a26b --- /dev/null +++ b/spec/factories/variant_factory.rb @@ -0,0 +1,34 @@ +FactoryBot.modify do + factory :variant do + trait :with_order_cycle do + transient do + order_cycle { create(:order_cycle) } + producer { product.supplier } + coordinator { create(:distributor_enterprise) } + distributor { create(:distributor_enterprise) } + incoming_exchange_fees { [] } + outgoing_exchange_fees { [] } + end + + after(:create) do |variant, evaluator| + exchange_attributes = { order_cycle_id: evaluator.order_cycle.id, incoming: true, + sender_id: evaluator.producer.id, + receiver_id: evaluator.coordinator.id } + exchange = Exchange.where(exchange_attributes).first_or_create!(exchange_attributes) + exchange.variants << variant + evaluator.incoming_exchange_fees.each do |enterprise_fee| + exchange.enterprise_fees << enterprise_fee + end + + exchange_attributes = { order_cycle_id: evaluator.order_cycle.id, incoming: false, + sender_id: evaluator.coordinator.id, + receiver_id: evaluator.distributor.id } + exchange = Exchange.where(exchange_attributes).first_or_create!(exchange_attributes) + exchange.variants << variant + (evaluator.outgoing_exchange_fees || []).each do |enterprise_fee| + exchange.enterprise_fees << enterprise_fee + end + end + end + end +end diff --git a/spec/features/admin/bulk_order_management_spec.rb b/spec/features/admin/bulk_order_management_spec.rb index 4fe7ab59bd..6c90af7243 100644 --- a/spec/features/admin/bulk_order_management_spec.rb +++ b/spec/features/admin/bulk_order_management_spec.rb @@ -439,7 +439,7 @@ feature %q{ expect(page).to have_selector "tr#li_#{li3.id}" fill_in "quick_search", :with => o1.email expect(page).to have_selector "tr#li_#{li1.id}" - expect(page).to have_no_selector "tr#li_#{li2.id}", true + expect(page).to have_no_selector "tr#li_#{li2.id}" expect(page).to have_no_selector "tr#li_#{li3.id}" end end @@ -567,6 +567,7 @@ feature %q{ context "when a filter has been applied" do it "only toggles checkboxes which are in filteredLineItems" do fill_in "quick_search", with: o1.number + expect(page).to have_no_selector "tr#li_#{li2.id}" check "toggle_bulk" fill_in "quick_search", with: '' expect(find("tr#li_#{li1.id} input[type='checkbox'][name='bulk']").checked?).to be true @@ -577,11 +578,13 @@ feature %q{ it "only applies the delete action to filteredLineItems" do check "toggle_bulk" fill_in "quick_search", with: o1.number + expect(page).to have_no_selector "tr#li_#{li2.id}" find("div#bulk-actions-dropdown").click find("div#bulk-actions-dropdown div.menu_item", :text => "Delete Selected" ).click - fill_in "quick_search", with: '' expect(page).to have_no_selector "tr#li_#{li1.id}" + fill_in "quick_search", with: '' expect(page).to have_selector "tr#li_#{li2.id}" + expect(page).to have_no_selector "tr#li_#{li1.id}" end end end @@ -740,10 +743,11 @@ feature %q{ end def select_date(date) - current_month = Time.zone.today.strftime("%B") - target_month = date.strftime("%B") + # Wait for datepicker to open and be associated to the datepicker trigger. + expect(page).to have_selector("#ui-datepicker-div") + + navigate_datepicker_to_month date - find('#ui-datepicker-div .ui-datepicker-header .ui-datepicker-prev').click if current_month != target_month find('#ui-datepicker-div .ui-datepicker-calendar .ui-state-default', text: date.strftime("%e").to_s.strip, exact_text: true).click end end diff --git a/spec/features/admin/bulk_product_update_spec.rb b/spec/features/admin/bulk_product_update_spec.rb index d8dfd95238..430b5b3344 100644 --- a/spec/features/admin/bulk_product_update_spec.rb +++ b/spec/features/admin/bulk_product_update_spec.rb @@ -776,9 +776,9 @@ feature %q{ # Shows spinner whilst loading expect(page).to have_css "img.spinner", visible: true - expect(page).to have_no_css "img.spinner", visible: true end + expect(page).to have_no_css "img.spinner", visible: true expect(page).to have_no_selector "div.reveal-modal" within "table#listing_products tr#p_#{product.id}" do diff --git a/spec/features/admin/product_import_spec.rb b/spec/features/admin/product_import_spec.rb index 424002ee0b..8114c129dc 100644 --- a/spec/features/admin/product_import_spec.rb +++ b/spec/features/admin/product_import_spec.rb @@ -294,6 +294,49 @@ feature "Product Import", js: true do expect(page).to have_content 'Cabbage' end end + + it "handles on_demand and on_hand validations with inventory" do + csv_data = CSV.generate do |csv| + csv << ["name", "distributor", "producer", "category", "on_hand", "price", "units", "on_demand"] + csv << ["Beans", "Another Enterprise", "User Enterprise", "Vegetables", nil, "3.20", "500", "true"] + csv << ["Sprouts", "Another Enterprise", "User Enterprise", "Vegetables", "6", "6.50", "500", "false"] + csv << ["Cabbage", "Another Enterprise", "User Enterprise", "Vegetables", nil, "1.50", "500", nil] + end + File.write('/tmp/test.csv', csv_data) + + visit main_app.admin_product_import_path + select2_select I18n.t('admin.product_import.index.inventories'), from: "settings_import_into" + attach_file 'file', '/tmp/test.csv' + click_button 'Upload' + + proceed_to_validation + + expect(page).to have_selector '.item-count', text: "3" + expect(page).to have_no_selector '.invalid-count' + expect(page).to have_selector '.inv-create-count', text: '2' + expect(page).to have_selector '.inv-update-count', text: '1' + + save_data + + expect(page).to have_selector '.inv-created-count', text: '2' + expect(page).to have_selector '.inv-updated-count', text: '1' + + beans_override = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first + sprouts_override = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first + cabbage_override = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first + + expect(Float(beans_override.price)).to eq 3.20 + expect(beans_override.count_on_hand).to be_nil + expect(beans_override.on_demand).to be_truthy + + expect(Float(sprouts_override.price)).to eq 6.50 + expect(sprouts_override.count_on_hand).to eq 6 + expect(sprouts_override.on_demand).to eq false + + expect(Float(cabbage_override.price)).to eq 1.50 + expect(cabbage_override.count_on_hand).to be_nil + expect(cabbage_override.on_demand).to be_nil + end end describe "when dealing with uploaded files" do diff --git a/spec/features/admin/reports/enterprise_fee_summaries_spec.rb b/spec/features/admin/reports/enterprise_fee_summaries_spec.rb new file mode 100644 index 0000000000..be03f7d393 --- /dev/null +++ b/spec/features/admin/reports/enterprise_fee_summaries_spec.rb @@ -0,0 +1,159 @@ +require "spec_helper" + +xfeature "enterprise fee summaries", js: true do + include AuthenticationWorkflow + include WebHelper + + let!(:distributor) { create(:distributor_enterprise) } + let!(:other_distributor) { create(:distributor_enterprise) } + + let!(:order_cycle) { create(:simple_order_cycle, coordinator: distributor) } + let!(:other_order_cycle) { create(:simple_order_cycle, coordinator: other_distributor) } + + before do + feature_flags = instance_double(FeatureFlags, enterprise_fee_summary_enabled?: true) + allow(FeatureFlags).to receive(:new).with(current_user) { feature_flags } + + login_as current_user + end + + describe "navigation" do + context "when accessing the report as an superadmin" do + let(:current_user) { create(:admin_user) } + + it "shows link and allows access to the report" do + visit spree.admin_reports_path + click_on I18n.t("admin.reports.enterprise_fee_summary.name") + expect(page).to have_button(I18n.t("filters.generate_report", scope: i18n_scope)) + end + end + + context "when accessing the report as an admin" do + let(:current_user) { distributor.owner } + + it "shows link and allows access to the report" do + visit spree.admin_reports_path + click_on I18n.t("admin.reports.enterprise_fee_summary.name") + expect(page).to have_button(I18n.t("filters.generate_report", scope: i18n_scope)) + end + + context "when feature flag is in effect" do + before { allow(FeatureFlags).to receive(:new).with(current_user).and_call_original } + + it "does not show link now allow direct access to the report" do + visit spree.admin_reports_path + expect(page).to have_no_link I18n.t("admin.reports.enterprise_fee_summary.name") + visit spree.new_admin_reports_enterprise_fee_summary_path + expect(page).to have_no_button(I18n.t("filters.generate_report", scope: i18n_scope)) + end + end + end + + context "when accessing the report as an enterprise user without sufficient permissions" do + let(:current_user) { create(:user) } + + it "does not allow access to the report" do + visit spree.admin_reports_path + expect(page).to have_no_link(I18n.t("admin.reports.enterprise_fee_summary.name")) + visit spree.new_admin_reports_enterprise_fee_summary_path + expect(page).to have_content(I18n.t("unauthorized")) + end + + context "when feature flag is in effect" do + before { allow(FeatureFlags).to receive(:new).with(current_user).and_call_original } + + it "does not show link now allow direct access to the report" do + visit spree.admin_reports_path + expect(page).to have_no_link I18n.t("admin.reports.enterprise_fee_summary.name") + visit spree.new_admin_reports_enterprise_fee_summary_path + expect(page).to have_no_button(I18n.t("filters.generate_report", scope: i18n_scope)) + end + end + end + end + + describe "smoke test for filters" do + before do + visit spree.new_admin_reports_enterprise_fee_summary_path + end + + context "when logged in as admin" do + let(:current_user) { create(:admin_user) } + + it "shows all available options" do + expect(page).to have_select "report_order_cycle_ids", with_options: [order_cycle.name] + end + end + + context "when logged in as enterprise user" do + let!(:order) { create(:completed_order_with_fees, order_cycle: order_cycle, distributor: distributor) } + + let(:current_user) { distributor.owner } + + it "shows available options for the enterprise" do + expect(page).to have_select "report_order_cycle_ids", options: [order_cycle.name] + end + end + end + + describe "smoke test for generation of report based on permissions" do + before do + visit spree.new_admin_reports_enterprise_fee_summary_path + end + + context "when logged in as admin" do + let!(:order) { create(:completed_order_with_fees, order_cycle: order_cycle, distributor: distributor) } + + let(:current_user) { create(:admin_user) } + + it "generates file with data for all enterprises" do + check I18n.t("filters.report_format_csv", scope: i18n_scope) + click_on I18n.t("filters.generate_report", scope: i18n_scope) + expect(page.response_headers['Content-Type']).to eq "text/csv" + expect(page.body).to have_content(distributor.name) + end + end + + context "when logged in as enterprise user" do + let!(:order) { create(:completed_order_with_fees, order_cycle: order_cycle, distributor: distributor) } + let!(:other_order) { create(:completed_order_with_fees, order_cycle: other_order_cycle, distributor: other_distributor) } + + let(:current_user) { distributor.owner } + + it "generates file with data for the enterprise" do + check I18n.t("filters.report_format_csv", scope: i18n_scope) + click_on I18n.t("filters.generate_report", scope: i18n_scope) + expect(page.response_headers['Content-Type']).to eq "text/csv" + expect(page.body).to have_content(distributor.name) + expect(page.body).not_to have_content(other_distributor.name) + end + end + end + + describe "smoke test for filtering report based on filters" do + let!(:second_distributor) { create(:distributor_enterprise) } + let!(:second_order_cycle) { create(:simple_order_cycle, coordinator: second_distributor) } + + let!(:order) { create(:completed_order_with_fees, order_cycle: order_cycle, distributor: distributor) } + let!(:second_order) { create(:completed_order_with_fees, order_cycle: second_order_cycle, distributor: second_distributor) } + + let(:current_user) { create(:admin_user) } + + before do + visit spree.new_admin_reports_enterprise_fee_summary_path + end + + it "generates file with data for selected order cycle" do + select order_cycle.name, from: "report_order_cycle_ids" + check I18n.t("filters.report_format_csv", scope: i18n_scope) + click_on I18n.t("filters.generate_report", scope: i18n_scope) + expect(page.response_headers['Content-Type']).to eq "text/csv" + expect(page.body).to have_content(distributor.name) + expect(page.body).not_to have_content(second_distributor.name) + end + end + + def i18n_scope + "spree.admin.reports.enterprise_fee_summaries" + end +end diff --git a/spec/features/admin/shipping_methods_spec.rb b/spec/features/admin/shipping_methods_spec.rb index f54a336838..d5f5eeceab 100644 --- a/spec/features/admin/shipping_methods_spec.rb +++ b/spec/features/admin/shipping_methods_spec.rb @@ -31,10 +31,13 @@ feature 'shipping methods' do check "shipping_method_distributor_ids_#{d1.id}" check "shipping_method_distributor_ids_#{d2.id}" check "shipping_method_shipping_categories_" - click_button 'Create' + click_button I18n.t("actions.create") + + expect(page).to have_no_button I18n.t("actions.create") # Then the shipping method should have its distributor set - expect(flash_message).to eq('Shipping method "Carrier Pidgeon" has been successfully created!') + message = "Shipping method \"Carrier Pidgeon\" has been successfully created!" + expect(page).to have_flash_message message sm = Spree::ShippingMethod.last expect(sm.name).to eq('Carrier Pidgeon') @@ -104,9 +107,12 @@ feature 'shipping methods' do expect(page).to have_css '.tag-item' end - click_button 'Create' + click_button I18n.t("actions.create") + + expect(page).to have_no_button I18n.t("actions.create") + message = "Shipping method \"Teleport\" has been successfully created!" + expect(page).to have_flash_message message - expect(flash_message).to eq('Shipping method "Teleport" has been successfully created!') expect(first('tags-input .tag-list ti-tag-item')).to have_content "local" shipping_method = Spree::ShippingMethod.find_by_name('Teleport') diff --git a/spec/features/admin/subscriptions_spec.rb b/spec/features/admin/subscriptions_spec.rb index 1862bdec14..175f6c8ca6 100644 --- a/spec/features/admin/subscriptions_spec.rb +++ b/spec/features/admin/subscriptions_spec.rb @@ -145,23 +145,25 @@ xfeature 'Subscriptions' do let!(:customer_user) { create(:user) } let!(:credit_card1) { create(:credit_card, user: customer_user, cc_type: 'visa', last_digits: 1111, month: 10, year: 2030) } let!(:customer) { create(:customer, enterprise: shop, bill_address: address, user: customer_user, allow_charges: true) } - let!(:product1) { create(:product, supplier: shop) } - let!(:product2) { create(:product, supplier: shop) } - let!(:variant1) { create(:variant, product: product1, unit_value: '100', price: 12.00, option_values: []) } - let!(:variant2) { create(:variant, product: product2, unit_value: '1000', price: 6.00, option_values: []) } + let!(:test_product) { create(:product, supplier: shop, distributors: []) } + let!(:test_variant) { create(:variant, product: test_product, unit_value: "100", price: 12.00, option_values: []) } + let!(:shop_product) { create(:product, supplier: shop, distributors: [shop]) } + let!(:shop_variant) { create(:variant, product: shop_product, unit_value: "1000", price: 6.00, option_values: []) } let!(:enterprise_fee) { create(:enterprise_fee, amount: 1.75) } let!(:order_cycle) { create(:simple_order_cycle, coordinator: shop, orders_open_at: 2.days.from_now, orders_close_at: 7.days.from_now) } - let!(:outgoing_exchange) { order_cycle.exchanges.create(sender: shop, receiver: shop, variants: [variant1, variant2], enterprise_fees: [enterprise_fee]) } + let!(:outgoing_exchange) { order_cycle.exchanges.create(sender: shop, receiver: shop, variants: [test_variant, shop_variant], enterprise_fees: [enterprise_fee]) } let!(:schedule) { create(:schedule, order_cycles: [order_cycle]) } let!(:payment_method) { create(:stripe_payment_method, name: 'Credit Card', distributors: [shop], preferred_enterprise_id: shop.id) } let!(:shipping_method) { create(:shipping_method, distributors: [shop]) } - it "passes the smoke test" do + before do visit admin_subscriptions_path - click_link 'New Subscription' - select2_select shop.name, from: 'new_subscription_shop_id' - click_button 'Continue' + click_link "New Subscription" + select2_select shop.name, from: "new_subscription_shop_id" + click_button "Continue" + end + it "passes the smoke test" do select2_select customer.email, from: 'customer_id' select2_select schedule.name, from: 'schedule_id' select2_select payment_method.name, from: 'payment_method_id' @@ -215,11 +217,9 @@ xfeature 'Subscriptions' do expect(page).to have_content 'Please add at least one product' # Adding a product and getting a price estimate - select2_search_async product1.name, from: I18n.t(:name_or_sku), dropdown_css: '.select2-drop' - fill_in 'add_quantity', with: 2 - click_link 'Add' + add_variant_to_subscription test_variant, 2 within 'table#subscription-line-items tr.item', match: :first do - expect(page).to have_selector 'td.description', text: "#{product1.name} - #{variant1.full_name}" + expect(page).to have_selector '.description', text: "#{test_product.name} - #{test_variant.full_name}" expect(page).to have_selector 'td.price', text: "$13.75" expect(page).to have_input 'quantity', with: "2" expect(page).to have_selector 'td.total', text: "$27.50" @@ -241,11 +241,9 @@ xfeature 'Subscriptions' do click_button('edit-products') # Adding a new product - select2_search_async product2.name, from: I18n.t(:name_or_sku), dropdown_css: '.select2-drop' - fill_in 'add_quantity', with: 3 - click_link 'Add' + add_variant_to_subscription shop_variant, 3 within 'table#subscription-line-items tr.item', match: :first do - expect(page).to have_selector 'td.description', text: "#{product2.name} - #{variant2.full_name}" + expect(page).to have_selector '.description', text: "#{shop_product.name} - #{shop_variant.full_name}" expect(page).to have_selector 'td.price', text: "$7.75" expect(page).to have_input 'quantity', with: "3" expect(page).to have_selector 'td.total', text: "$23.25" @@ -264,7 +262,7 @@ xfeature 'Subscriptions' do # Prices are shown in the index within 'table#subscription-line-items tr.item', match: :first do - expect(page).to have_selector 'td.description', text: "#{product2.name} - #{variant2.full_name}" + expect(page).to have_selector '.description', text: "#{shop_product.name} - #{shop_variant.full_name}" expect(page).to have_selector 'td.price', text: "$7.75" expect(page).to have_input 'quantity', with: "3" expect(page).to have_selector 'td.total', text: "$23.25" @@ -282,142 +280,249 @@ xfeature 'Subscriptions' do # Standing Line Items are created expect(subscription.subscription_line_items.count).to eq 1 subscription_line_item = subscription.subscription_line_items.first - expect(subscription_line_item.variant).to eq variant2 + expect(subscription_line_item.variant).to eq shop_variant expect(subscription_line_item.quantity).to eq 3 end + end - context 'editing an existing subscription' do - let!(:customer) { create(:customer, enterprise: shop) } - let!(:product1) { create(:product, supplier: shop) } - let!(:product2) { create(:product, supplier: shop) } - let!(:product3) { create(:product, supplier: shop) } - let!(:variant1) { create(:variant, product: product1, unit_value: '100', price: 12.00, option_values: []) } - let!(:variant2) { create(:variant, product: product2, unit_value: '1000', price: 6.00, option_values: []) } - let!(:variant3) { create(:variant, product: product3, unit_value: '10000', price: 22.00, option_values: []) } - let!(:enterprise_fee) { create(:enterprise_fee, amount: 1.75) } - let!(:order_cycle) { create(:simple_order_cycle, coordinator: shop, orders_open_at: 2.days.from_now, orders_close_at: 7.days.from_now) } - let!(:outgoing_exchange) { order_cycle.exchanges.create(sender: shop, receiver: shop, variants: [variant1, variant2], enterprise_fees: [enterprise_fee]) } - let!(:schedule) { create(:schedule, order_cycles: [order_cycle]) } - let!(:variant3_oc) { create(:simple_order_cycle, coordinator: shop, orders_open_at: 2.days.from_now, orders_close_at: 7.days.from_now) } - let!(:variant3_ex) { variant3_oc.exchanges.create(sender: shop, receiver: shop, variants: [variant3]) } - let!(:payment_method) { create(:payment_method, distributors: [shop]) } - let!(:stripe_payment_method) { create(:stripe_payment_method, name: 'Credit Card', distributors: [shop], preferred_enterprise_id: shop.id) } - let!(:shipping_method) { create(:shipping_method, distributors: [shop]) } - let!(:subscription) { - create(:subscription, - shop: shop, - customer: customer, - schedule: schedule, - payment_method: payment_method, - shipping_method: shipping_method, - subscription_line_items: [create(:subscription_line_item, variant: variant1, quantity: 2, price_estimate: 13.75)], - with_proxy_orders: true) - } + context 'editing an existing subscription' do + let!(:customer) { create(:customer, enterprise: shop) } + let!(:product1) { create(:product, supplier: shop) } + let!(:product2) { create(:product, supplier: shop) } + let!(:product3) { create(:product, supplier: shop) } + let!(:variant1) { create(:variant, product: product1, unit_value: '100', price: 12.00, option_values: []) } + let!(:variant2) { create(:variant, product: product2, unit_value: '1000', price: 6.00, option_values: []) } + let!(:variant3) { create(:variant, product: product3, unit_value: '10000', price: 22.00, option_values: []) } + let!(:enterprise_fee) { create(:enterprise_fee, amount: 1.75) } + let!(:order_cycle) { create(:simple_order_cycle, coordinator: shop, orders_open_at: 2.days.from_now, orders_close_at: 7.days.from_now) } + let!(:outgoing_exchange) { order_cycle.exchanges.create(sender: shop, receiver: shop, variants: [variant1, variant2], enterprise_fees: [enterprise_fee]) } + let!(:schedule) { create(:schedule, order_cycles: [order_cycle]) } + let!(:variant3_oc) { create(:simple_order_cycle, coordinator: shop, orders_open_at: 2.days.from_now, orders_close_at: 7.days.from_now) } + let!(:variant3_ex) { variant3_oc.exchanges.create(sender: shop, receiver: shop, variants: [variant3]) } + let!(:payment_method) { create(:payment_method, distributors: [shop]) } + let!(:stripe_payment_method) { create(:stripe_payment_method, name: 'Credit Card', distributors: [shop], preferred_enterprise_id: shop.id) } + let!(:shipping_method) { create(:shipping_method, distributors: [shop]) } + let!(:subscription) { + create(:subscription, + shop: shop, + customer: customer, + schedule: schedule, + payment_method: payment_method, + shipping_method: shipping_method, + subscription_line_items: [create(:subscription_line_item, variant: variant1, quantity: 2, price_estimate: 13.75)], + with_proxy_orders: true) + } - it "passes the smoke test" do - visit edit_admin_subscription_path(subscription) + it "passes the smoke test" do + visit edit_admin_subscription_path(subscription) - # Customer and Schedule cannot be edited - click_button 'edit-details' - expect(page).to have_selector '#s2id_customer_id.select2-container-disabled' - expect(page).to have_selector '#s2id_schedule_id.select2-container-disabled' + # Customer and Schedule cannot be edited + click_button 'edit-details' + expect(page).to have_selector '#s2id_customer_id.select2-container-disabled' + expect(page).to have_selector '#s2id_schedule_id.select2-container-disabled' - # Can't use a Stripe payment method because customer does not allow it - select2_select stripe_payment_method.name, from: 'payment_method_id' - expect(page).to have_content I18n.t('admin.subscriptions.details.charges_not_allowed') - click_button 'Save Changes' - expect(page).to have_content 'Credit card charges are not allowed by this customer' - select2_select payment_method.name, from: 'payment_method_id' - click_button 'Review' + # Can't use a Stripe payment method because customer does not allow it + select2_select stripe_payment_method.name, from: 'payment_method_id' + expect(page).to have_content I18n.t('admin.subscriptions.details.charges_not_allowed') + click_button 'Save Changes' + expect(page).to have_content 'Credit card charges are not allowed by this customer' + select2_select payment_method.name, from: 'payment_method_id' + click_button 'Review' - # Existing products should be visible - click_button 'edit-products' - within "#sli_0" do - expect(page).to have_selector 'td.description', text: "#{product1.name} - #{variant1.full_name}" - expect(page).to have_selector 'td.price', text: "$13.75" - expect(page).to have_input 'quantity', with: "2" - expect(page).to have_selector 'td.total', text: "$27.50" + # Existing products should be visible + click_button 'edit-products' + within "#sli_0" do + expect(page).to have_selector '.description', text: "#{product1.name} - #{variant1.full_name}" + expect(page).to have_selector 'td.price', text: "$13.75" + expect(page).to have_input 'quantity', with: "2" + expect(page).to have_selector 'td.total', text: "$27.50" - # Remove variant1 from the subscription - find("a.delete-item").click - end - - # Attempting to submit without a product - click_button 'Save Changes' - expect(page).to have_content 'Please add at least one product' - - # Add variant2 to the subscription - select2_search_async product2.name, from: I18n.t(:name_or_sku), dropdown_css: '.select2-drop' - fill_in 'add_quantity', with: 1 - click_link 'Add' - within "#sli_0" do - expect(page).to have_selector 'td.description', text: "#{product2.name} - #{variant2.full_name}" - expect(page).to have_selector 'td.price', text: "$7.75" - expect(page).to have_input 'quantity', with: "1" - expect(page).to have_selector 'td.total', text: "$7.75" - end - - # Total should be $7.75 - expect(page).to have_selector '#order_form_total', text: "$7.75" - - # Add variant3 to the subscription (even though it is not available) - select2_search_async product3.name, from: I18n.t(:name_or_sku), dropdown_css: '.select2-drop' - fill_in 'add_quantity', with: 1 - click_link 'Add' - within "#sli_1" do - expect(page).to have_selector 'td.description', text: "#{product3.name} - #{variant3.full_name}" - expect(page).to have_selector 'td.price', text: "$22.00" - expect(page).to have_input 'quantity', with: "1" - expect(page).to have_selector 'td.total', text: "$22.00" - end - - # Total should be $29.75 - expect(page).to have_selector '#order_form_total', text: "$29.75" - - click_button 'Save Changes' - expect(page).to have_content "#{product3.name} - #{variant3.full_name} is not available from the selected schedule" - - # Remove variant3 from the subscription - within '#sli_1' do - find("a.delete-item").click - end - - click_button 'Save Changes' - expect(page).to have_current_path admin_subscriptions_path - - select2_select shop.name, from: "shop_id" - expect(page).to have_selector "td.items.panel-toggle" - first("td.items.panel-toggle").click - - # Total should be $7.75 - expect(page).to have_selector '#order_form_total', text: "$7.75" - expect(page).to have_selector 'tr.item', count: 1 - expect(subscription.reload.subscription_line_items.length).to eq 1 - expect(subscription.subscription_line_items.first.variant).to eq variant2 + # Remove variant1 from the subscription + find("a.delete-item").click end - context "with initialised order that has been changed" do - let(:proxy_order) { subscription.proxy_orders.first } - let(:order) { proxy_order.initialise_order! } - let(:line_item) { order.line_items.first } + # Attempting to submit without a product + click_button 'Save Changes' + expect(page).to have_content 'Please add at least one product' - before { line_item.update_attributes(quantity: 3) } + # Add variant2 to the subscription + add_variant_to_subscription(variant2, 1) + within "#sli_0" do + expect(page).to have_selector '.description', text: "#{product2.name} - #{variant2.full_name}" + expect(page).to have_selector 'td.price', text: "$7.75" + expect(page).to have_input 'quantity', with: "1" + expect(page).to have_selector 'td.total', text: "$7.75" + end - it "reports issues encountered during the update" do - visit edit_admin_subscription_path(subscription) - click_button 'edit-products' + # Total should be $7.75 + expect(page).to have_selector '#order_form_total', text: "$7.75" - within "#sli_0" do - fill_in 'quantity', with: "1" - end + # Add variant3 to the subscription (even though it is not available) + add_variant_to_subscription(variant3, 1) + within "#sli_1" do + expect(page).to have_selector '.description', text: "#{product3.name} - #{variant3.full_name}" + expect(page).to have_selector 'td.price', text: "$22.00" + expect(page).to have_input 'quantity', with: "1" + expect(page).to have_selector 'td.total', text: "$22.00" + end - click_button 'Save Changes' - expect(page).to have_content 'Saved' + # Total should be $29.75 + expect(page).to have_selector '#order_form_total', text: "$29.75" - expect(page).to have_selector "#order_update_issues_dialog .message", text: I18n.t("admin.subscriptions.order_update_issues_msg") + # Remove variant3 from the subscription + within '#sli_1' do + find("a.delete-item").click + end + + click_button 'Save Changes' + expect(page).to have_current_path admin_subscriptions_path + + select2_select shop.name, from: "shop_id" + expect(page).to have_selector "td.items.panel-toggle" + first("td.items.panel-toggle").click + + # Total should be $7.75 + expect(page).to have_selector '#order_form_total', text: "$7.75" + expect(page).to have_selector 'tr.item', count: 1 + expect(subscription.reload.subscription_line_items.length).to eq 1 + expect(subscription.subscription_line_items.first.variant).to eq variant2 + end + + context "with initialised order that has been changed" do + let(:proxy_order) { subscription.proxy_orders.first } + let(:order) { proxy_order.initialise_order! } + let(:line_item) { order.line_items.first } + + before { line_item.update_attributes(quantity: 3) } + + it "reports issues encountered during the update" do + visit edit_admin_subscription_path(subscription) + click_button 'edit-products' + + within "#sli_0" do + fill_in 'quantity', with: "1" end + + click_button 'Save Changes' + expect(page).to have_content 'Saved' + + expect(page).to have_selector "#order_update_issues_dialog .message", text: I18n.t("admin.subscriptions.order_update_issues_msg") end end end + + describe "allowed variants" do + let!(:customer) { create(:customer, enterprise: shop, allow_charges: true) } + let!(:credit_card) { create(:credit_card, user: customer.user) } + let!(:shop_product) { create(:product, supplier: shop, distributors: [shop]) } + let!(:shop_variant) { create(:variant, product: shop_product, unit_value: "2000") } + let!(:permitted_supplier) do + create(:supplier_enterprise).tap do |supplier| + create(:enterprise_relationship, child: shop, parent: supplier, permissions_list: [:add_to_order_cycle]) + end + end + let!(:permitted_supplier_product) { create(:product, supplier: permitted_supplier, distributors: [shop]) } + let!(:permitted_supplier_variant) { create(:variant, product: permitted_supplier_product, unit_value: "2000") } + let!(:incoming_exchange_product) { create(:product, distributors: [shop]) } + let!(:incoming_exchange_variant) do + create(:variant, product: incoming_exchange_product, unit_value: "2000").tap do |variant| + create(:exchange, order_cycle: order_cycle, incoming: true, receiver: shop, variants: [variant]) + end + end + let!(:outgoing_exchange_product) { create(:product, distributors: [shop]) } + let!(:outgoing_exchange_variant) do + create(:variant, product: outgoing_exchange_product, unit_value: "2000").tap do |variant| + create(:exchange, order_cycle: order_cycle, incoming: false, receiver: shop, variants: [variant]) + end + end + let!(:enterprise_fee) { create(:enterprise_fee, amount: 1.75) } + let!(:order_cycle) { create(:simple_order_cycle, coordinator: shop) } + let!(:schedule) { create(:schedule, order_cycles: [order_cycle]) } + let!(:payment_method) { create(:stripe_payment_method, distributors: [shop], preferred_enterprise_id: shop.id) } + let!(:shipping_method) { create(:shipping_method, distributors: [shop]) } + + before do + visit admin_subscriptions_path + click_link "New Subscription" + select2_select shop.name, from: "new_subscription_shop_id" + click_button "Continue" + end + + it "permit creating and editing of the subscription" do + # Fill in other details + fill_in_subscription_basic_details + click_button "Next" + expect(page).to have_content "BILLING ADDRESS" + click_button "Next" + + # Add products + expect(page).to have_content "NAME OR SKU" + add_variant_to_subscription shop_variant, 3 + expect_not_in_open_or_upcoming_order_cycle_warning 1 + add_variant_to_subscription permitted_supplier_variant, 4 + expect_not_in_open_or_upcoming_order_cycle_warning 2 + add_variant_to_subscription incoming_exchange_variant, 5 + expect_not_in_open_or_upcoming_order_cycle_warning 3 + add_variant_to_subscription outgoing_exchange_variant, 6 + expect_not_in_open_or_upcoming_order_cycle_warning 3 + click_button "Next" + + # Submit form + expect { + click_button "Create Subscription" + expect(page).to have_current_path admin_subscriptions_path + }.to change(Subscription, :count).by(1) + + # Subscription line items are created + subscription = Subscription.last + expect(subscription.subscription_line_items.count).to eq 4 + + # Edit the subscription + visit edit_admin_subscription_path(subscription) + + # Remove shop_variant from the subscription + click_button "edit-products" + within "#sli_0" do + expect(page).to have_selector ".description", text: shop_variant.name + find("a.delete-item").click + end + + # Submit form + click_button "Save Changes" + expect(page).to have_current_path admin_subscriptions_path + + # Subscription is saved + visit edit_admin_subscription_path(subscription) + expect(page).to have_selector "#subscription-line-items .item", count: 3 + end + end + end + + def fill_in_subscription_basic_details + select2_select customer.email, from: "customer_id" + select2_select schedule.name, from: "schedule_id" + select2_select payment_method.name, from: "payment_method_id" + select2_select shipping_method.name, from: "shipping_method_id" + + find_field("begins_at").click + choose_today_from_datepicker + end + + def expect_not_in_open_or_upcoming_order_cycle_warning(count) + expect(page).to have_content variant_not_in_open_or_upcoming_order_cycle_warning, count: count + end + + def add_variant_to_subscription(variant, quantity) + row_count = all("#subscription-line-items .item").length + variant_name = variant.full_name.present? ? "#{variant.name} - #{variant.full_name}" : variant.name + select2_search variant.name, from: I18n.t(:name_or_sku), dropdown_css: ".select2-drop", select_text: variant_name + fill_in "add_quantity", with: quantity + click_link "Add" + expect(page).to have_selector("#subscription-line-items .item", count: row_count + 1) + end + + def variant_not_in_open_or_upcoming_order_cycle_warning + I18n.t("not_in_open_and_upcoming_order_cycles_warning", + scope: "admin.subscriptions.subscription_line_items") end end diff --git a/spec/models/feature_flags_spec.rb b/spec/models/feature_flags_spec.rb index 3a2d06da30..6b9380b879 100644 --- a/spec/models/feature_flags_spec.rb +++ b/spec/models/feature_flags_spec.rb @@ -1,10 +1,10 @@ require 'spec_helper' describe FeatureFlags do - describe '.product_import_enabled?' do - let(:user) { build_stubbed(:user) } - let(:feature_flags) { described_class.new(user) } + let(:user) { build_stubbed(:user) } + let(:feature_flags) { described_class.new(user) } + describe '#product_import_enabled?' do context 'when the user is superadmin' do before do allow(user).to receive(:has_spree_role?).with('admin') { true } @@ -25,4 +25,22 @@ describe FeatureFlags do end end end + + describe "#enterprise_fee_summary_enabled?" do + context "when the user is superadmin" do + let!(:user) { create(:admin_user) } + + it "returns true" do + expect(feature_flags).to be_enterprise_fee_summary_enabled + end + end + + context "when the user is not superadmin" do + let!(:user) { create(:user) } + + it "returns false" do + expect(feature_flags).not_to be_enterprise_fee_summary_enabled + end + end + end end diff --git a/spec/models/product_importer_spec.rb b/spec/models/product_importer_spec.rb index 1cb36581a2..68e304c236 100644 --- a/spec/models/product_importer_spec.rb +++ b/spec/models/product_importer_spec.rb @@ -26,7 +26,7 @@ xdescribe ProductImport::ProductImporter do let!(:variant) { create(:variant, product_id: product.id, price: '8.50', on_hand: '100', unit_value: '500', display_name: 'Preexisting Banana') } let!(:product2) { create(:simple_product, supplier: enterprise, on_hand: '100', name: 'Beans', unit_value: '500', primary_taxon_id: category.id, description: nil) } let!(:product3) { create(:simple_product, supplier: enterprise, on_hand: '100', name: 'Sprouts', unit_value: '500', primary_taxon_id: category.id) } - let!(:product4) { create(:simple_product, supplier: enterprise, on_hand: '100', name: 'Cabbage', unit_value: '500', primary_taxon_id: category.id) } + let!(:product4) { create(:simple_product, supplier: enterprise, on_hand: '100', name: 'Cabbage', unit_value: '1', variant_unit_scale: nil, variant_unit: "items", variant_unit_name: "Whole", primary_taxon_id: category.id) } let!(:product5) { create(:simple_product, supplier: enterprise2, on_hand: '100', name: 'Lettuce', unit_value: '500', primary_taxon_id: category.id) } let!(:product6) { create(:simple_product, supplier: enterprise3, on_hand: '100', name: 'Beetroot', unit_value: '500', on_demand: true, variant_unit_scale: 1, variant_unit: 'weight', primary_taxon_id: category.id, description: nil) } let!(:product7) { create(:simple_product, supplier: enterprise3, on_hand: '100', name: 'Tomato', unit_value: '500', variant_unit_scale: 1, variant_unit: 'weight', primary_taxon_id: category.id, description: nil) } @@ -467,50 +467,106 @@ xdescribe ProductImport::ProductImporter do end describe "importing items into inventory" do - before do - csv_data = CSV.generate do |csv| - csv << ["name", "distributor", "producer", "on_hand", "price", "units", "unit_type"] - csv << ["Beans", "Another Enterprise", "User Enterprise", "5", "3.20", "500", "g"] - csv << ["Sprouts", "Another Enterprise", "User Enterprise", "6", "6.50", "500", "g"] - csv << ["Cabbage", "Another Enterprise", "User Enterprise", "2001", "1.50", "500", "g"] + describe "creating and updating inventory" do + before do + csv_data = CSV.generate do |csv| + csv << ["name", "distributor", "producer", "on_hand", "price", "units", "unit_type", "variant_unit_name"] + csv << ["Beans", "Another Enterprise", "User Enterprise", "5", "3.20", "500", "g", ""] + csv << ["Sprouts", "Another Enterprise", "User Enterprise", "6", "6.50", "500", "g", ""] + csv << ["Cabbage", "Another Enterprise", "User Enterprise", "2001", "1.50", "1", "", "Whole"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + settings = {'import_into' => 'inventories'} + @importer = ProductImport::ProductImporter.new(file, admin, start: 1, end: 100, settings: settings) end - File.write('/tmp/test-m.csv', csv_data) - file = File.new('/tmp/test-m.csv') - settings = {'import_into' => 'inventories'} - @importer = ProductImport::ProductImporter.new(file, admin, start: 1, end: 100, settings: settings) - end - after { File.delete('/tmp/test-m.csv') } + after { File.delete('/tmp/test-m.csv') } - it "validates entries" do - @importer.validate_entries - entries = JSON.parse(@importer.entries_json) + it "validates entries" do + @importer.validate_entries + entries = JSON.parse(@importer.entries_json) - expect(filter('valid', entries)).to eq 3 - expect(filter('invalid', entries)).to eq 0 - expect(filter('create_inventory', entries)).to eq 2 - expect(filter('update_inventory', entries)).to eq 1 + expect(filter('valid', entries)).to eq 3 + expect(filter('invalid', entries)).to eq 0 + expect(filter('create_inventory', entries)).to eq 2 + expect(filter('update_inventory', entries)).to eq 1 + end + + it "saves and updates inventory" do + @importer.save_entries + + expect(@importer.inventory_created_count).to eq 2 + expect(@importer.inventory_updated_count).to eq 1 + expect(@importer.updated_ids).to be_a(Array) + expect(@importer.updated_ids.count).to eq 3 + + beans_override = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first + sprouts_override = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first + cabbage_override = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first + + expect(Float(beans_override.price)).to eq 3.20 + expect(beans_override.count_on_hand).to eq 5 + + expect(Float(sprouts_override.price)).to eq 6.50 + expect(sprouts_override.count_on_hand).to eq 6 + + expect(Float(cabbage_override.price)).to eq 1.50 + expect(cabbage_override.count_on_hand).to eq 2001 + end end - it "saves and updates inventory" do - @importer.save_entries + describe "updating existing inventory referenced by display_name" do + before do + csv_data = CSV.generate do |csv| + csv << ["name", "display_name", "distributor", "producer", "on_hand", "price", "units"] + csv << ["Oats", "Porridge Oats", "Another Enterprise", "User Enterprise", "900", "", "500"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + settings = {'import_into' => 'inventories'} + @importer = ProductImport::ProductImporter.new(file, admin, start: 1, end: 100, settings: settings) + end + after { File.delete('/tmp/test-m.csv') } - expect(@importer.inventory_created_count).to eq 2 - expect(@importer.inventory_updated_count).to eq 1 - expect(@importer.updated_ids).to be_a(Array) - expect(@importer.updated_ids.count).to eq 3 + it "updates inventory item correctly" do + @importer.save_entries - beans_override = VariantOverride.where(variant_id: product2.variants.first.id, hub_id: enterprise2.id).first - sprouts_override = VariantOverride.where(variant_id: product3.variants.first.id, hub_id: enterprise2.id).first - cabbage_override = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first + expect(@importer.inventory_created_count).to eq 1 - expect(Float(beans_override.price)).to eq 3.20 - expect(beans_override.count_on_hand).to eq 5 + override = VariantOverride.where(variant_id: variant2.id, hub_id: enterprise2.id).first + visible = InventoryItem.where(variant_id: variant2.id, enterprise_id: enterprise2.id).first.visible - expect(Float(sprouts_override.price)).to eq 6.50 - expect(sprouts_override.count_on_hand).to eq 6 + expect(override.count_on_hand).to eq 900 + expect(visible).to be_truthy + end + end - expect(Float(cabbage_override.price)).to eq 1.50 - expect(cabbage_override.count_on_hand).to eq 2001 + describe "updating existing item that was set to hidden in inventory" do + before do + InventoryItem.create(variant_id: product4.variants.first.id, enterprise_id: enterprise2.id, visible: false) + + csv_data = CSV.generate do |csv| + csv << ["name", "distributor", "producer", "on_hand", "price", "units", "variant_unit_name"] + csv << ["Cabbage", "Another Enterprise", "User Enterprise", "900", "", "1", "Whole"] + end + File.write('/tmp/test-m.csv', csv_data) + file = File.new('/tmp/test-m.csv') + settings = {'import_into' => 'inventories'} + @importer = ProductImport::ProductImporter.new(file, admin, start: 1, end: 100, settings: settings) + end + after { File.delete('/tmp/test-m.csv') } + + it "sets the item to visible in inventory when the item is updated" do + @importer.save_entries + + expect(@importer.inventory_updated_count).to eq 1 + + override = VariantOverride.where(variant_id: product4.variants.first.id, hub_id: enterprise2.id).first + visible = InventoryItem.where(variant_id: product4.variants.first.id, enterprise_id: enterprise2.id).first.visible + + expect(override.count_on_hand).to eq 900 + expect(visible).to be_truthy + end end end diff --git a/spec/models/spree/ability_spec.rb b/spec/models/spree/ability_spec.rb index ab9946c0ff..0e83a9719a 100644 --- a/spec/models/spree/ability_spec.rb +++ b/spec/models/spree/ability_spec.rb @@ -4,9 +4,11 @@ require 'support/cancan_helper' module Spree describe User do - describe "broad permissions" do subject { AbilityDecorator.new(user) } + + include ::AbilityHelper + let(:user) { create(:user) } let(:enterprise_any) { create(:enterprise, sells: 'any') } let(:enterprise_own) { create(:enterprise, sells: 'own') } @@ -215,6 +217,8 @@ module Spree should have_ability([:admin, :index, :customers, :bulk_coop, :orders_and_fulfillment, :products_and_inventory, :order_cycle_management], for: :report) end + include_examples "allows access to Enterprise Fee Summary only if feature flag enabled" + it "should not be able to read other reports" do should_not have_ability([:sales_total, :group_buys, :payments, :orders_and_distributors, :users_and_enterprises, :xero_invoices], for: :report) end @@ -406,6 +410,8 @@ module Spree should have_ability([:admin, :index, :customers, :sales_tax, :group_buys, :bulk_coop, :payments, :orders_and_distributors, :orders_and_fulfillment, :products_and_inventory, :order_cycle_management, :xero_invoices], for: :report) end + include_examples "allows access to Enterprise Fee Summary only if feature flag enabled" + it "should not be able to read other reports" do should_not have_ability([:sales_total, :users_and_enterprises], for: :report) end diff --git a/spec/models/spree/payment_method_spec.rb b/spec/models/spree/payment_method_spec.rb index 8b62281c26..1c021c2ce2 100644 --- a/spec/models/spree/payment_method_spec.rb +++ b/spec/models/spree/payment_method_spec.rb @@ -43,5 +43,24 @@ module Spree order.add_variant(product.master) expect(flat_percent_payment_method.compute_amount(order)).to eq 2.0 end + + describe "scope" do + describe "filtering to specified distributors" do + let!(:distributor_a) { create(:distributor_enterprise) } + let!(:distributor_b) { create(:distributor_enterprise) } + let!(:distributor_c) { create(:distributor_enterprise) } + + let!(:payment_method_a) { create(:payment_method, distributors: [distributor_a, distributor_b]) } + let!(:payment_method_b) { create(:payment_method, distributors: [distributor_b]) } + let!(:payment_method_c) { create(:payment_method, distributors: [distributor_c]) } + + it "includes only unique records under specified distributors" do + result = described_class.for_distributors([distributor_a, distributor_b]) + expect(result.length).to eq(2) + expect(result).to include(payment_method_a) + expect(result).to include(payment_method_b) + end + end + end end end diff --git a/spec/models/spree/shipping_method_spec.rb b/spec/models/spree/shipping_method_spec.rb index 7872772b2b..d6d4ce61fb 100644 --- a/spec/models/spree/shipping_method_spec.rb +++ b/spec/models/spree/shipping_method_spec.rb @@ -18,13 +18,32 @@ module Spree sm.reload.distributors.should match_array [d1, d2] end - it "finds shipping methods for a particular distributor" do - d1 = create(:distributor_enterprise) - d2 = create(:distributor_enterprise) - sm1 = create(:shipping_method, distributors: [d1]) - sm2 = create(:shipping_method, distributors: [d2]) + describe "scope" do + describe "filtering to specified distributors" do + let!(:distributor_a) { create(:distributor_enterprise) } + let!(:distributor_b) { create(:distributor_enterprise) } + let!(:distributor_c) { create(:distributor_enterprise) } - ShippingMethod.for_distributor(d1).should == [sm1] + let!(:shipping_method_a) { create(:shipping_method, distributors: [distributor_a, distributor_b]) } + let!(:shipping_method_b) { create(:shipping_method, distributors: [distributor_b]) } + let!(:shipping_method_c) { create(:shipping_method, distributors: [distributor_c]) } + + it "includes only unique records under specified distributors" do + result = described_class.for_distributors([distributor_a, distributor_b]) + expect(result.length).to eq(2) + expect(result).to include(shipping_method_a) + expect(result).to include(shipping_method_b) + end + end + + it "finds shipping methods for a particular distributor" do + d1 = create(:distributor_enterprise) + d2 = create(:distributor_enterprise) + sm1 = create(:shipping_method, distributors: [d1]) + sm2 = create(:shipping_method, distributors: [d2]) + + ShippingMethod.for_distributor(d1).should == [sm1] + end end it "orders shipping methods by name" do diff --git a/spec/services/subscription_validator_spec.rb b/spec/services/subscription_validator_spec.rb index 6670d14fa4..bdcd14bea5 100644 --- a/spec/services/subscription_validator_spec.rb +++ b/spec/services/subscription_validator_spec.rb @@ -1,8 +1,11 @@ +require "spec_helper" + describe SubscriptionValidator do - let(:shop) { instance_double(Enterprise, name: "Shop") } + let(:owner) { create(:user) } + let(:shop) { create(:enterprise, name: "Shop", owner: owner) } describe "delegation" do - let(:subscription) { create(:subscription) } + let(:subscription) { create(:subscription, shop: shop) } let(:validator) { SubscriptionValidator.new(subscription) } it "delegates to subscription" do @@ -438,6 +441,7 @@ describe SubscriptionValidator do context "but some variants are unavailable" do let(:product) { instance_double(Spree::Product, name: "some_name") } + before do allow(validator).to receive(:available_variant_ids) { [variant2.id] } allow(variant1).to receive(:product) { product } @@ -451,7 +455,9 @@ describe SubscriptionValidator do end context "and all requested variants are available" do - before { allow(validator).to receive(:available_variant_ids) { [variant1.id, variant2.id] } } + before do + allow(validator).to receive(:available_variant_ids) { [variant1.id, variant2.id] } + end it "returns true" do expect(validator.valid?).to be true diff --git a/spec/services/subscription_variants_service_spec.rb b/spec/services/subscription_variants_service_spec.rb new file mode 100644 index 0000000000..31d0ff4ca7 --- /dev/null +++ b/spec/services/subscription_variants_service_spec.rb @@ -0,0 +1,130 @@ +require "spec_helper" + +describe SubscriptionVariantsService do + describe "variant eligibility for subscription" do + let!(:shop) { create(:distributor_enterprise) } + let!(:producer) { create(:supplier_enterprise) } + let!(:product) { create(:product, supplier: producer) } + let!(:variant) { product.variants.first } + + let!(:schedule) { create(:schedule, order_cycles: [order_cycle]) } + let!(:subscription) { create(:subscription, shop: shop, schedule: schedule) } + let!(:subscription_line_item) do + create(:subscription_line_item, subscription: subscription, variant: variant) + end + + let(:current_order_cycle) do + create(:simple_order_cycle, coordinator: shop, orders_open_at: 1.week.ago, + orders_close_at: 1.week.from_now) + end + + let(:future_order_cycle) do + create(:simple_order_cycle, coordinator: shop, orders_open_at: 1.week.from_now, + orders_close_at: 2.weeks.from_now) + end + + let(:past_order_cycle) do + create(:simple_order_cycle, coordinator: shop, orders_open_at: 2.weeks.ago, + orders_close_at: 1.week.ago) + end + + let!(:order_cycle) { current_order_cycle } + + context "if the shop is the supplier for the product" do + let!(:producer) { shop } + + it "is eligible" do + expect(described_class.eligible_variants(shop)).to include(variant) + end + end + + context "if the supplier is permitted for the shop" do + let!(:enterprise_relationship) { create(:enterprise_relationship, child: shop, parent: product.supplier, permissions_list: [:add_to_order_cycle]) } + + it "is eligible" do + expect(described_class.eligible_variants(shop)).to include(variant) + end + end + + context "if the variant is involved in an exchange" do + let!(:order_cycle) { create(:simple_order_cycle, coordinator: shop) } + let!(:schedule) { create(:schedule, order_cycles: [order_cycle]) } + + context "if it is an incoming exchange where the shop is the receiver" do + let!(:incoming_exchange) { order_cycle.exchanges.create(sender: product.supplier, receiver: shop, incoming: true, variants: [variant]) } + + it "is not eligible" do + expect(described_class.eligible_variants(shop)).to_not include(variant) + end + end + + context "if it is an outgoing exchange where the shop is the receiver" do + let!(:outgoing_exchange) { order_cycle.exchanges.create(sender: product.supplier, receiver: shop, incoming: false, variants: [variant]) } + + context "if the order cycle is currently open" do + let!(:order_cycle) { current_order_cycle } + + it "is eligible" do + expect(described_class.eligible_variants(shop)).to include(variant) + end + end + + context "if the order cycle opens in the future" do + let!(:order_cycle) { future_order_cycle } + + it "is eligible" do + expect(described_class.eligible_variants(shop)).to include(variant) + end + end + + context "if the order cycle closed in the past" do + let!(:order_cycle) { past_order_cycle } + + it "is eligible" do + expect(described_class.eligible_variants(shop)).to include(variant) + end + end + end + end + + context "if the variant is unrelated" do + it "is not eligible" do + expect(described_class.eligible_variants(shop)).to_not include(variant) + end + end + end + + describe "checking if variant in open and upcoming order cycles" do + let!(:shop) { create(:enterprise) } + let!(:product) { create(:product) } + let!(:variant) { product.variants.first } + let!(:schedule) { create(:schedule) } + + context "if the variant is involved in an exchange" do + let!(:order_cycle) { create(:simple_order_cycle, coordinator: shop) } + let!(:schedule) { create(:schedule, order_cycles: [order_cycle]) } + + context "if it is an incoming exchange where the shop is the receiver" do + let!(:incoming_exchange) { order_cycle.exchanges.create(sender: product.supplier, receiver: shop, incoming: true, variants: [variant]) } + + it "is is false" do + expect(described_class).not_to be_in_open_and_upcoming_order_cycles(shop, schedule, variant) + end + end + + context "if it is an outgoing exchange where the shop is the receiver" do + let!(:outgoing_exchange) { order_cycle.exchanges.create(sender: product.supplier, receiver: shop, incoming: false, variants: [variant]) } + + it "is true" do + expect(described_class).to be_in_open_and_upcoming_order_cycles(shop, schedule, variant) + end + end + end + + context "if the variant is unrelated" do + it "is false" do + expect(described_class).to_not be_in_open_and_upcoming_order_cycles(shop, schedule, variant) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f9437c3ceb..68a523c4af 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -135,6 +135,7 @@ RSpec.configure do |config| config.extend Spree::Api::TestingSupport::Setup, :type => :controller config.include Spree::Api::TestingSupport::Helpers, :type => :controller config.include OpenFoodNetwork::ControllerHelper, :type => :controller + config.include Features::DatepickerHelper, type: :feature config.include OpenFoodNetwork::FeatureToggleHelper config.include OpenFoodNetwork::FiltersHelper config.include OpenFoodNetwork::EnterpriseGroupsHelper diff --git a/spec/support/ability_helper.rb b/spec/support/ability_helper.rb new file mode 100644 index 0000000000..42c4418a80 --- /dev/null +++ b/spec/support/ability_helper.rb @@ -0,0 +1,28 @@ +module AbilityHelper + shared_examples "allows access to Enterprise Fee Summary only if feature flag enabled" do + it "should not be able to read Enterprise Fee Summary" do + is_expected.not_to have_link_to_enterprise_fee_summary + is_expected.not_to have_direct_access_to_enterprise_fee_summary + end + + context "when feature flag for Enterprise Fee Summary is enabled absolutely" do + before do + feature_flags = instance_double(FeatureFlags, enterprise_fee_summary_enabled?: true) + allow(FeatureFlags).to receive(:new).with(user) { feature_flags } + end + + it "should be able to see link and read report" do + is_expected.to have_link_to_enterprise_fee_summary + is_expected.to have_direct_access_to_enterprise_fee_summary + end + end + + def have_link_to_enterprise_fee_summary + have_ability([:enterprise_fee_summary], for: :report) + end + + def have_direct_access_to_enterprise_fee_summary + have_ability([:admin, :new, :create], for: :enterprise_fee_summary) + end + end +end diff --git a/spec/support/features/datepicker_helper.rb b/spec/support/features/datepicker_helper.rb new file mode 100644 index 0000000000..60221c6635 --- /dev/null +++ b/spec/support/features/datepicker_helper.rb @@ -0,0 +1,33 @@ +module Features + module DatepickerHelper + def choose_today_from_datepicker + within(".ui-datepicker-calendar") do + find(".ui-datepicker-today").click + end + end + + def navigate_datepicker_to_month(date, reference_date = Time.zone.today) + month_and_year = date.strftime("%B %Y") + + until datepicker_month_and_year == month_and_year.upcase + if date < reference_date + navigate_datepicker_to_previous_month + elsif date > reference_date + navigate_datepicker_to_next_month + end + end + end + + def navigate_datepicker_to_previous_month + find('#ui-datepicker-div .ui-datepicker-header .ui-datepicker-prev').click + end + + def navigate_datepicker_to_next_month + find('#ui-datepicker-div .ui-datepicker-header .ui-datepicker-next').click + end + + def datepicker_month_and_year + find("#ui-datepicker-div .ui-datepicker-title").text + end + end +end diff --git a/spec/support/matchers/date_time_validator_matchers.rb b/spec/support/matchers/date_time_validator_matchers.rb new file mode 100644 index 0000000000..f0fdaf678c --- /dev/null +++ b/spec/support/matchers/date_time_validator_matchers.rb @@ -0,0 +1,30 @@ +# RSpec matcher for DateTimeValidator +# +# Usage: +# +# describe Post do +# it { should validate_date_time_format_of(:start_at) } +# end +RSpec::Matchers.define :validate_date_time_format_of do |attribute| + match do |instance| + @instance, @attribute = instance, attribute + + invalid_format_message = I18n.t("validators.date_time_string_validator.invalid_format_error") + + allow(instance).to receive(attribute) { "Invalid Format" } + instance.valid? + (instance.errors[attribute] || []).include?(invalid_format_message) + end + + description do + "validates :#{@attribute} has datetime format" + end + + failure_message do + "expected #{@instance} to validate format of :#{@attribute} is datetime" + end + + failure_message_when_negated do + "expected #{@instance} not to validate format of :#{@attribute} is datetime" + end +end diff --git a/spec/support/matchers/flash_message_matchers.rb b/spec/support/matchers/flash_message_matchers.rb new file mode 100644 index 0000000000..b781fc1811 --- /dev/null +++ b/spec/support/matchers/flash_message_matchers.rb @@ -0,0 +1,31 @@ +RSpec::Matchers.define :have_flash_message do |message| + match do |node| + @message, @node = message, node + + # Ignore leading and trailing whitespace. Later versions of Capybara have :exact_text option. + # The :exact option is not supported in has_selector?. + message_substring_regex = substring_match_regex(message) + node.has_selector?(".flash", text: message_substring_regex, visible: false) + end + + failure_message do |actual| + "expected to find flash message ##{@message}" + end + + match_when_negated do |node| + @message, @node = message, node + + # Ignore leading and trailing whitespace. Later versions of Capybara have :exact_text option. + # The :exact option is not supported in has_selector?. + message_substring_regex = substring_match_regex(message) + node.has_no_selector?(".flash", text: message_substring_regex, visible: false) + end + + failure_message_when_negated do |actual| + "expected not to find flash message ##{@message}" + end + + def substring_match_regex(text) + /\A\s*#{Regexp.escape(text)}\s*\Z/ + end +end diff --git a/spec/support/matchers/integer_array_validator_matchers.rb b/spec/support/matchers/integer_array_validator_matchers.rb new file mode 100644 index 0000000000..44cff638aa --- /dev/null +++ b/spec/support/matchers/integer_array_validator_matchers.rb @@ -0,0 +1,30 @@ +# RSpec matcher for IntegerArrayValidator +# +# Usage: +# +# describe Post do +# it { should validate_integer_array(:related_post_ids) } +# end +RSpec::Matchers.define :validate_integer_array do |attribute| + match do |instance| + @instance, @attribute = instance, attribute + + invalid_format_message = I18n.t("validators.integer_array_validator.invalid_element_error") + + allow(instance).to receive(attribute) { [1, "2", "Not Integer", 3] } + instance.valid? + (instance.errors[attribute] || []).include?(invalid_format_message) + end + + description do + "validates :#{@attribute} is integer array" + end + + failure_message do + "expected #{@instance} to validate :#{@attribute} is integer array" + end + + failure_message_when_negated do + "expected #{@instance} not to validate :#{@attribute} is integer array" + end +end diff --git a/spec/support/request/web_helper.rb b/spec/support/request/web_helper.rb index c37014e637..912c19e445 100644 --- a/spec/support/request/web_helper.rb +++ b/spec/support/request/web_helper.rb @@ -105,6 +105,16 @@ module WebHelper targetted_select2(value, options) end + # Support having different texts to search for and to click in the select2 + # field. + # + # This overrides the method in Spree. + def targetted_select2_search(value, options) + page.execute_script %Q{$('#{options[:from]}').select2('open')} + page.execute_script "$('#{options[:dropdown_css]} input.select2-input').val('#{value}').trigger('keyup-change');" + select_select2_result(options[:select_text] || value) + end + def multi_select2_select(value, options) find("#s2id_#{options[:from]}").find('ul li.select2-search-field').click select_select2_result(value) diff --git a/spec/validators/date_time_string_validator_spec.rb b/spec/validators/date_time_string_validator_spec.rb new file mode 100644 index 0000000000..d5151fc7c5 --- /dev/null +++ b/spec/validators/date_time_string_validator_spec.rb @@ -0,0 +1,58 @@ +require "spec_helper" + +describe DateTimeStringValidator do + class TestModel + include ActiveModel::Validations + + attr_accessor :timestamp + + validates :timestamp, date_time_string: true + end + + describe "internationalization" do + it "has translation for NOT_STRING_ERROR" do + expect(described_class::NOT_STRING_ERROR).not_to be_blank + end + + it "has translation for INVALID_FORMAT_ERROR" do + expect(described_class::INVALID_FORMAT_ERROR).not_to be_blank + end + end + + describe "validation" do + let(:instance) { TestModel.new } + + it "does not add error when nil" do + instance.timestamp = nil + expect(instance).to be_valid + end + + it "does not add error when blank string" do + instance.timestamp = nil + expect(instance).to be_valid + end + + it "adds error NOT_STRING_ERROR when blank but neither nil nor a string" do + instance.timestamp = [] + expect(instance).not_to be_valid + expect(instance.errors[:timestamp]).to eq([described_class::NOT_STRING_ERROR]) + end + + it "adds error NOT_STRING_ERROR when not a string" do + instance.timestamp = 1 + expect(instance).not_to be_valid + expect(instance.errors[:timestamp]).to eq([described_class::NOT_STRING_ERROR]) + end + + it "does not add error when value can be parsed" do + instance.timestamp = "2018-09-20 01:02:00 +10:00" + expect(instance).to be_valid + end + + it "adds error INVALID_FORMAT_ERROR when value cannot be parsed" do + instance.timestamp = "Not Valid" + expect(instance).not_to be_valid + expect(instance.errors[:timestamp]).to eq([described_class::INVALID_FORMAT_ERROR]) + end + end +end diff --git a/spec/validators/integer_array_validator_spec.rb b/spec/validators/integer_array_validator_spec.rb new file mode 100644 index 0000000000..156409f0d0 --- /dev/null +++ b/spec/validators/integer_array_validator_spec.rb @@ -0,0 +1,57 @@ +require "spec_helper" + +describe IntegerArrayValidator do + class TestModel + include ActiveModel::Validations + + attr_accessor :ids + + validates :ids, integer_array: true + end + + describe "internationalization" do + it "has translation for NOT_ARRAY_ERROR" do + expect(described_class::NOT_ARRAY_ERROR).not_to be_blank + end + + it "has translation for INVALID_ELEMENT_ERROR" do + expect(described_class::INVALID_ELEMENT_ERROR).not_to be_blank + end + end + + describe "validation" do + let(:instance) { TestModel.new } + + it "does not add error when nil" do + instance.ids = nil + expect(instance).to be_valid + end + + it "does not add error when blank array" do + instance.ids = [] + expect(instance).to be_valid + end + + it "adds error NOT_ARRAY_ERROR when neither nil nor an array" do + instance.ids = 1 + expect(instance).not_to be_valid + expect(instance.errors[:ids]).to include(described_class::NOT_ARRAY_ERROR) + end + + it "does not add error when array of integers" do + instance.ids = [1, 2, 3] + expect(instance).to be_valid + end + + it "does not add error when array of integers as String" do + instance.ids = ["1", "2", "3"] + expect(instance).to be_valid + end + + it "adds error INVALID_ELEMENT_ERROR when an element cannot be parsed as Integer" do + instance.ids = [1, "2", "Not Integer", 3] + expect(instance).not_to be_valid + expect(instance.errors[:ids]).to include(described_class::INVALID_ELEMENT_ERROR) + end + end +end