diff --git a/app/controllers/admin/dfc_product_imports_controller.rb b/app/controllers/admin/dfc_product_imports_controller.rb index 4e87b6b2ac..eae8c092b5 100644 --- a/app/controllers/admin/dfc_product_imports_controller.rb +++ b/app/controllers/admin/dfc_product_imports_controller.rb @@ -14,13 +14,13 @@ module Admin def index # Fetch DFC catalog JSON for preview - api = DfcRequest.new(spree_current_user) @catalog_url = params.require(:catalog_url).strip @catalog_json = api.call(@catalog_url) catalog = DfcCatalog.from_json(@catalog_json) # Render table and let user decide which ones to import. @items = list_products(catalog) + @absent_items = importer(catalog).absent_variants rescue URI::InvalidURIError flash[:error] = t ".invalid_url" redirect_to admin_product_import_path @@ -58,6 +58,7 @@ module Admin end @count = imported.compact.count + @reset_count = importer(catalog).reset_absent_variants.count rescue ActionController::ParameterMissing => e flash[:error] = e.message redirect_to admin_product_import_path @@ -65,6 +66,10 @@ module Admin private + def api + @api ||= DfcRequest.new(spree_current_user) + end + def load_enterprise @enterprise = OpenFoodNetwork::Permissions.new(spree_current_user) .managed_product_enterprises.is_primary_producer @@ -80,5 +85,9 @@ module Admin ] end end + + def importer(catalog) + DfcCatalogImporter.new(@enterprise.supplied_variants, catalog) + end end end diff --git a/app/jobs/open_order_cycle_job.rb b/app/jobs/open_order_cycle_job.rb index 80c4e1d7b8..c2f7dc9469 100644 --- a/app/jobs/open_order_cycle_job.rb +++ b/app/jobs/open_order_cycle_job.rb @@ -60,7 +60,11 @@ class OpenOrderCycleJob < ApplicationJob catalog_links.each do |link| catalog_item = catalog.item(link.semantic_id) - SuppliedProductImporter.update_product(catalog_item, link.subject) if catalog_item + if catalog_item + SuppliedProductImporter.update_product(catalog_item, link.subject) + else + DfcCatalogImporter.reset_variant(link.subject) + end end end end diff --git a/app/services/dfc_catalog_importer.rb b/app/services/dfc_catalog_importer.rb new file mode 100644 index 0000000000..16d81ac6d3 --- /dev/null +++ b/app/services/dfc_catalog_importer.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class DfcCatalogImporter + def self.reset_variant(variant) + if variant.on_demand + variant.on_demand = false + else + variant.on_hand = 0 + end + end + + attr_reader :catalog, :existing_variants + + def initialize(existing_variants, catalog) + @existing_variants = existing_variants + @catalog = catalog + end + + # Reset stock for any variants that were removed from the catalog. + # + # When variants are removed from the remote catalog, we can't place + # backorders for them anymore. If our copy of the product has limited + # stock then we need to set the stock to zero to prevent any more sales. + # + # But if our product is on-demand/backorderable then our stock level is + # a representation of remaining local stock. We then need to limit sales + # to this local stock and set on-demand to false. + # + # We don't delete the variant because it may come back at a later time and + # we don't want to lose the connection to previous orders. + def reset_absent_variants + absent_variants.map do |variant| + self.class.reset_variant(variant) + end + end + + def absent_variants + present_ids = catalog.products.map(&:semanticId) + catalog_url = FdcUrlBuilder.new(present_ids.first).catalog_url + + existing_variants + .includes(:semantic_links).references(:semantic_links) + .where.not(semantic_links: { semantic_id: present_ids }) + .select do |variant| + # Variants that were in the same catalog before: + variant.semantic_links.map(&:semantic_id).any? do |semantic_id| + FdcUrlBuilder.new(semantic_id).catalog_url == catalog_url + end + end + end +end diff --git a/app/views/admin/dfc_product_imports/_absent_variant.html.haml b/app/views/admin/dfc_product_imports/_absent_variant.html.haml new file mode 100644 index 0000000000..e0bd9bad91 --- /dev/null +++ b/app/views/admin/dfc_product_imports/_absent_variant.html.haml @@ -0,0 +1,8 @@ +%tr + %td + %label + ❌ + = absent_variant.product_and_full_name + %td + = t(".reset") + = link_to(absent_variant.product_id, edit_admin_product_path(absent_variant.product_id)) diff --git a/app/views/admin/dfc_product_imports/import.html.haml b/app/views/admin/dfc_product_imports/import.html.haml index 60a3f7a05a..63ca01a33b 100644 --- a/app/views/admin/dfc_product_imports/import.html.haml +++ b/app/views/admin/dfc_product_imports/import.html.haml @@ -3,5 +3,6 @@ = render partial: 'spree/admin/shared/product_sub_menu' -%p= t(".imported_products") -= @count +%p= t(".imported_products", count: @count) + +%p= t(".reset_products", count: @reset_count) if @reset_count.positive? diff --git a/app/views/admin/dfc_product_imports/index.html.haml b/app/views/admin/dfc_product_imports/index.html.haml index 720705d373..1b170e01da 100644 --- a/app/views/admin/dfc_product_imports/index.html.haml +++ b/app/views/admin/dfc_product_imports/index.html.haml @@ -6,6 +6,7 @@ %p= t('.catalog_url', count: @items.count, catalog_url: @catalog_url) %p= t('.enterprise', enterprise_name: @enterprise.name) + %p= t('.absent_products', count: @absent_items.count) %br = form_with url: main_app.import_admin_dfc_product_imports_path, html: { "data-controller": "checked" } do |form| @@ -32,6 +33,7 @@ = link_to(existing_product.id, edit_admin_product_path(existing_product)) - else = t(".new") + = render partial: "absent_variant", collection: @absent_items %span{ "data-controller": "checked-feedback", "data-checked-feedback-translation-value": "admin.dfc_product_imports.index.selected" } = t(".selected", count: @items.count) diff --git a/config/locales/el.yml b/config/locales/el.yml index 0779ee2a0b..1ba478b6ec 100644 --- a/config/locales/el.yml +++ b/config/locales/el.yml @@ -725,7 +725,7 @@ el: other: "%{count}επιλέχθηκε" import: Εισαγωγή import: - imported_products: "Εισαγόμενα προϊόντα:" + imported_products: "Εισαγόμενα προϊόντα: %{count}" enterprise_fees: index: title: "Τέλη επιχείρησης" diff --git a/config/locales/en.yml b/config/locales/en.yml index f0d5938f2f..e7fd2b7466 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -850,9 +850,19 @@ en: connection_invalid_html: | Connecting with your OIDC account failed. Please refresh your OIDC connection at: %{oidc_settings_link} + absent_variant: + reset: "Reset stock" index: title: "DFC product catalog" catalog_url: "%{count} products to be imported from: %{catalog_url}" + absent_products: + zero: "" + one: | + One product is no longer in the catalog. + It will be marked as unavailable by resetting stock to zero. + other: | + %{count} products are no longer in the catalog. + They will be marked as unavailable by resetting stock to zero. enterprise: "Import to enterprise: %{enterprise_name}" select_all: "Select/deselect all" update: Update @@ -865,7 +875,8 @@ en: invalid_url: This catalog URL is not valid. import: title: "DFC product catalog import" - imported_products: "Imported products:" + imported_products: "Imported products: %{count}" + reset_products: "Stock reset for absent products: %{count}" enterprise_fees: index: title: "Enterprise Fees" diff --git a/config/locales/en_CA.yml b/config/locales/en_CA.yml index 7f8669a823..44bb02e4f4 100644 --- a/config/locales/en_CA.yml +++ b/config/locales/en_CA.yml @@ -798,7 +798,7 @@ en_CA: invalid_url: This catalog URL is not valid. import: title: "DFC product catalog import" - imported_products: "Imported products:" + imported_products: "Imported products: %{count}" enterprise_fees: index: title: "Enterprise Fees" diff --git a/config/locales/en_FR.yml b/config/locales/en_FR.yml index ec77bce756..f3f87561b3 100644 --- a/config/locales/en_FR.yml +++ b/config/locales/en_FR.yml @@ -798,7 +798,7 @@ en_FR: invalid_url: This catalog URL is not valid. import: title: "DFC product catalog import" - imported_products: "Imported products:" + imported_products: "Imported products: %{count}" enterprise_fees: index: title: "Enterprise Fees" diff --git a/config/locales/en_GB.yml b/config/locales/en_GB.yml index 6b6b533001..b99833ca3d 100644 --- a/config/locales/en_GB.yml +++ b/config/locales/en_GB.yml @@ -771,7 +771,7 @@ en_GB: other: "%{count} selected" import: Import import: - imported_products: "Imported products:" + imported_products: "Imported products: %{count}" enterprise_fees: index: title: "Enterprise Fees" diff --git a/config/locales/en_IE.yml b/config/locales/en_IE.yml index 0e3e4e6bed..3832b68985 100644 --- a/config/locales/en_IE.yml +++ b/config/locales/en_IE.yml @@ -771,7 +771,7 @@ en_IE: other: "%{count} selected" import: Import import: - imported_products: "Imported products:" + imported_products: "Imported products: %{count}" enterprise_fees: index: title: "Enterprise Fees" diff --git a/config/locales/es.yml b/config/locales/es.yml index db86456a33..b031dba48c 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -765,7 +765,7 @@ es: new: Nuevo import: Importar import: - imported_products: "Productos importados:" + imported_products: "Productos importados: %{count}" enterprise_fees: index: title: "Comisiones de la Organización" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index eaaa176a8c..53f037fd83 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -797,7 +797,7 @@ fr: invalid_url: L'URL de ce catalogue n'est pas valide. import: title: "Import du catalogue produit DFC" - imported_products: "Produits importés :" + imported_products: "Produits importés : %{count}" enterprise_fees: index: title: "Marges et Commissions" diff --git a/config/locales/fr_CA.yml b/config/locales/fr_CA.yml index b3f0d9a9a2..278cc3cdea 100644 --- a/config/locales/fr_CA.yml +++ b/config/locales/fr_CA.yml @@ -798,7 +798,7 @@ fr_CA: invalid_url: L'URL de ce catalogue n'est pas valide. import: title: "Import du catalogue produit DFC" - imported_products: "Produits importés :" + imported_products: "Produits importés : %{count}" enterprise_fees: index: title: "Marges et Commissions" diff --git a/config/locales/hu.yml b/config/locales/hu.yml index e92ac1ec5a..de9f7ba186 100644 --- a/config/locales/hu.yml +++ b/config/locales/hu.yml @@ -760,7 +760,7 @@ hu: other: "%{count}kiválasztva" import: Importálás import: - imported_products: "Importált termékek:" + imported_products: "Importált termékek: %{count}" enterprise_fees: index: title: "Vállalkozási díjak" diff --git a/config/locales/nb.yml b/config/locales/nb.yml index 608986a54d..d1fd993d80 100644 --- a/config/locales/nb.yml +++ b/config/locales/nb.yml @@ -771,7 +771,7 @@ nb: other: "%{count} valgt" import: Import import: - imported_products: "Importerte produkter:" + imported_products: "Importerte produkter: %{count}" enterprise_fees: index: title: "Bedriftsavgifter" diff --git a/config/locales/ru.yml b/config/locales/ru.yml index 7770ffa1b1..65f77cc331 100644 --- a/config/locales/ru.yml +++ b/config/locales/ru.yml @@ -711,7 +711,7 @@ ru: other: "выбрано %{count}" import: Импорт import: - imported_products: "Внесенные товары:" + imported_products: "Внесенные товары: %{count}" enterprise_fees: index: title: "Сборы Предприятия" diff --git a/spec/jobs/open_order_cycle_job_spec.rb b/spec/jobs/open_order_cycle_job_spec.rb index 5c8574535e..c6d1d1d27e 100644 --- a/spec/jobs/open_order_cycle_job_spec.rb +++ b/spec/jobs/open_order_cycle_job_spec.rb @@ -33,21 +33,34 @@ RSpec.describe OpenOrderCycleJob do let(:enterprise) { create(:supplier_enterprise) } let!(:variant) { create(:variant, name: "Sauce", supplier_id: enterprise.id) } + let!(:variant_discontinued) { + create(:variant, name: "Shiraz 1971", supplier_id: enterprise.id) + } let!(:order_cycle) { - create(:simple_order_cycle, orders_open_at: now, - suppliers: [enterprise], variants: [variant]) + create( + :simple_order_cycle, + orders_open_at: now, + suppliers: [enterprise], + variants: [variant, variant_discontinued] + ) } it "synchronises products from a FDC catalog", vcr: true do user.update!(oidc_account: build(:testdfc_account)) - # One product is existing in OFN + # One current product is existing in OFN product_id = "https://env-0105831.jcloud-ver-jpe.ik-server.com/api/dfc/Enterprises/test-hodmedod/SuppliedProducts/44519466467635" variant.semantic_links << SemanticLink.new(semantic_id: product_id) + # One discontinued product is existing in OFN + old_product_id = + "https://env-0105831.jcloud-ver-jpe.ik-server.com/api/dfc/Enterprises/test-hodmedod/SuppliedProducts/44519466467635-disc" + variant_discontinued.semantic_links << SemanticLink.new(semantic_id: old_product_id) + expect { subject variant.reload + variant_discontinued.reload order_cycle.reload }.to change { order_cycle.opened_at } .and change { enterprise.supplied_products.count }.by(0) # It shouldn't add, only update @@ -57,7 +70,8 @@ RSpec.describe OpenOrderCycleJob do .and change { variant.price }.to(1.57) .and change { variant.on_demand }.to(true) .and change { variant.on_hand }.by(0) - .and query_database 46 + .and change { variant_discontinued.on_hand }.to(0) + .and query_database 58 end end diff --git a/spec/system/admin/dfc_product_import_spec.rb b/spec/system/admin/dfc_product_import_spec.rb index 03c86eb379..a7d8b53c3f 100644 --- a/spec/system/admin/dfc_product_import_spec.rb +++ b/spec/system/admin/dfc_product_import_spec.rb @@ -9,6 +9,7 @@ RSpec.describe "DFC Product Import" do let(:user) { create(:oidc_user, owned_enterprises: [enterprise]) } let(:enterprise) { create(:supplier_enterprise, name: "Saucy preserves") } let(:source_product) { create(:product, name: "Sauce", supplier_id: enterprise.id) } + let(:old_product) { create(:product, name: "Best Sauce of 1995", supplier_id: enterprise.id) } before do login_as user @@ -52,12 +53,21 @@ RSpec.describe "DFC Product Import" do it "imports from a FDC catalog", vcr: true do user.update!(oidc_account: build(:testdfc_account)) - # One product is existing in OFN + + # One current product is existing in OFN product_id = "https://env-0105831.jcloud-ver-jpe.ik-server.com/api/dfc/Enterprises/test-hodmedod/SuppliedProducts/44519466467635" linked_variant = source_product.variants.first linked_variant.semantic_links << SemanticLink.new(semantic_id: product_id) + # One outdated product still exists in OFN + old_product_id = + "https://env-0105831.jcloud-ver-jpe.ik-server.com/api/dfc/Enterprises/test-hodmedod/SuppliedProducts/445194664-1995" + unlinked_variant = old_product.variants.first + unlinked_variant.semantic_links << SemanticLink.new(semantic_id: old_product_id) + unlinked_variant.on_demand = true + unlinked_variant.on_hand = 3 + visit admin_product_import_path url = "https://env-0105831.jcloud-ver-jpe.ik-server.com/api/dfc/Enterprises/test-hodmedod/SuppliedProducts" @@ -66,11 +76,13 @@ RSpec.describe "DFC Product Import" do click_button "Preview" expect(page).to have_content "4 products to be imported" + expect(page).to have_content "One product is no longer" expect(page).to have_content "Saucy preserves" expect(page).not_to have_content "Sauce - 1g" # Does not show other product expect(page).to have_content "Beans - Retail can, 400g (can) Update" # existing product expect(page).to have_content "Beans - Case, 12 x 400g (can) New" expect(page).to have_content "Chia Seed, Organic - Retail pack, 300g" + expect(page).to have_content "Best Sauce of 1995 - 1g Reset stock" # I can select all uncheck "Chia Seed, Organic - Case, 8 x 300g" @@ -83,14 +95,18 @@ RSpec.describe "DFC Product Import" do expect { click_button "Import" expect(page).to have_content "Imported products: 3" + expect(page).to have_content "Stock reset for absent products: 1" linked_variant.reload - }.to change { enterprise.supplied_products.count }.by(2) # 1 updated, 2 new + unlinked_variant.reload + }.to change { enterprise.supplied_products.count }.by(2) # 1 updated, 2 new, 1 reset .and change { linked_variant.display_name } .and change { linked_variant.unit_value } # 18.85 wholesale variant price divided by 12 cans in the slab. .and change { linked_variant.price }.to(1.57) .and change { linked_variant.on_demand }.to(true) .and change { linked_variant.on_hand }.by(0) + .and change { unlinked_variant.on_demand }.to(false) + .and change { unlinked_variant.on_hand }.by(0) product = Spree::Product.last expect(product.variants[0].semantic_links).to be_present