From 4342d3b912dea52945ae71c1528cfabe007b8f10 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Wed, 28 Aug 2024 15:06:11 +1000 Subject: [PATCH 1/5] Add DFC API endpoint for sales data --- .../affiliate_sales_data_controller.rb | 11 +++++++++ engines/dfc_provider/config/routes.rb | 2 ++ .../requests/affiliate_sales_data_spec.rb | 23 +++++++++++++++++++ swagger/dfc.yaml | 17 ++++++++++++++ 4 files changed, 53 insertions(+) create mode 100644 engines/dfc_provider/app/controllers/dfc_provider/affiliate_sales_data_controller.rb create mode 100644 engines/dfc_provider/spec/requests/affiliate_sales_data_spec.rb diff --git a/engines/dfc_provider/app/controllers/dfc_provider/affiliate_sales_data_controller.rb b/engines/dfc_provider/app/controllers/dfc_provider/affiliate_sales_data_controller.rb new file mode 100644 index 00000000000..be26749cbdc --- /dev/null +++ b/engines/dfc_provider/app/controllers/dfc_provider/affiliate_sales_data_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module DfcProvider + # Aggregates anonymised sales data for a research project. + class AffiliateSalesDataController < DfcProvider::ApplicationController + def show + person = PersonBuilder.person(current_user) + render json: DfcIo.export(person) + end + end +end diff --git a/engines/dfc_provider/config/routes.rb b/engines/dfc_provider/config/routes.rb index 5bde8f23ba1..8fc08507291 100644 --- a/engines/dfc_provider/config/routes.rb +++ b/engines/dfc_provider/config/routes.rb @@ -12,4 +12,6 @@ resources :affiliated_by, only: [:create, :destroy], module: 'enterprise_groups' end resources :persons, only: [:show] + + resource :affiliate_sales_data, only: [:show] end diff --git a/engines/dfc_provider/spec/requests/affiliate_sales_data_spec.rb b/engines/dfc_provider/spec/requests/affiliate_sales_data_spec.rb new file mode 100644 index 00000000000..08fc18817f7 --- /dev/null +++ b/engines/dfc_provider/spec/requests/affiliate_sales_data_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require_relative "../swagger_helper" + +RSpec.describe "AffiliateSalesData", swagger_doc: "dfc.yaml", rswag_autodoc: true do + let(:user) { create(:oidc_user, id: 10_000) } + + before { login_as user } + + path "/api/dfc/affiliate_sales_data" do + get "Show sales data of person's affiliate enterprises" do + produces "application/json" + + response "200", "successful" do + run_test! do + expect(json_response).to include( + "@id" => "http://test.host/api/dfc/persons/10000", + ) + end + end + end + end +end diff --git a/swagger/dfc.yaml b/swagger/dfc.yaml index cc6fa07771d..d8d0cd8ed6b 100644 --- a/swagger/dfc.yaml +++ b/swagger/dfc.yaml @@ -69,6 +69,23 @@ paths: dfc-b:region: Victoria '404': description: not found + "/api/dfc/affiliate_sales_data": + get: + summary: Show sales data of person's affiliate enterprises + tags: + - AffiliateSalesData + responses: + '200': + description: successful + content: + application/json: + examples: + test_example: + value: + "@context": https://www.datafoodconsortium.org + "@id": http://test.host/api/dfc/persons/10000 + "@type": dfc-b:Person + dfc-b:logo: '' "/api/dfc/enterprises/{enterprise_id}/catalog_items": parameters: - name: enterprise_id From ce28c10c7ed81f6284ff5ff3c8c21bf10efd16e3 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Wed, 28 Aug 2024 15:38:45 +1000 Subject: [PATCH 2/5] Move sales data generation to a service object There will be lots and lots. The sales data root object is also the authenticated person. The data has its own URL (semantic id) which doens't need to contain the user id. The service object can also be tested more easily. I'm setting up the test data here. --- .../affiliate_sales_data_controller.rb | 3 +- .../services/affiliate_sales_data_builder.rb | 11 ++++ .../requests/affiliate_sales_data_spec.rb | 5 +- .../affiliate_sales_data_builder_spec.rb | 54 +++++++++++++++++++ swagger/dfc.yaml | 10 +++- 5 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 engines/dfc_provider/app/services/affiliate_sales_data_builder.rb create mode 100644 engines/dfc_provider/spec/services/affiliate_sales_data_builder_spec.rb diff --git a/engines/dfc_provider/app/controllers/dfc_provider/affiliate_sales_data_controller.rb b/engines/dfc_provider/app/controllers/dfc_provider/affiliate_sales_data_controller.rb index be26749cbdc..73b09af76cb 100644 --- a/engines/dfc_provider/app/controllers/dfc_provider/affiliate_sales_data_controller.rb +++ b/engines/dfc_provider/app/controllers/dfc_provider/affiliate_sales_data_controller.rb @@ -4,7 +4,8 @@ module DfcProvider # Aggregates anonymised sales data for a research project. class AffiliateSalesDataController < DfcProvider::ApplicationController def show - person = PersonBuilder.person(current_user) + person = AffiliateSalesDataBuilder.person + render json: DfcIo.export(person) end end diff --git a/engines/dfc_provider/app/services/affiliate_sales_data_builder.rb b/engines/dfc_provider/app/services/affiliate_sales_data_builder.rb new file mode 100644 index 00000000000..1b2e99ec3c2 --- /dev/null +++ b/engines/dfc_provider/app/services/affiliate_sales_data_builder.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AffiliateSalesDataBuilder < DfcBuilder + class << self + def person + DataFoodConsortium::Connector::Person.new( + urls.affiliate_sales_data_url, + ) + end + end +end diff --git a/engines/dfc_provider/spec/requests/affiliate_sales_data_spec.rb b/engines/dfc_provider/spec/requests/affiliate_sales_data_spec.rb index 08fc18817f7..b8d03b881fc 100644 --- a/engines/dfc_provider/spec/requests/affiliate_sales_data_spec.rb +++ b/engines/dfc_provider/spec/requests/affiliate_sales_data_spec.rb @@ -3,7 +3,7 @@ require_relative "../swagger_helper" RSpec.describe "AffiliateSalesData", swagger_doc: "dfc.yaml", rswag_autodoc: true do - let(:user) { create(:oidc_user, id: 10_000) } + let(:user) { create(:oidc_user) } before { login_as user } @@ -14,7 +14,8 @@ response "200", "successful" do run_test! do expect(json_response).to include( - "@id" => "http://test.host/api/dfc/persons/10000", + "@id" => "http://test.host/api/dfc/affiliate_sales_data", + "@type" => "dfc-b:Person", ) end end diff --git a/engines/dfc_provider/spec/services/affiliate_sales_data_builder_spec.rb b/engines/dfc_provider/spec/services/affiliate_sales_data_builder_spec.rb new file mode 100644 index 00000000000..cd8ef1cc544 --- /dev/null +++ b/engines/dfc_provider/spec/services/affiliate_sales_data_builder_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +RSpec.describe AffiliateSalesDataBuilder do + let(:user) { build(:user) } + + describe ".person" do + let(:person) { described_class.person(user) } + + it "returns data as Person" do + expect(person).to be_a DataFoodConsortium::Connector::Person + expect(person.semanticId).to eq "http://test.host/api/dfc/affiliate_sales_data" + end + + it "returns required sales data" do + supplier = create( + :supplier_enterprise, + owner: user, + users: [user], + address: create(:address, zipcode: "5555"), + ) + product = create( + :product, + supplier_id: supplier.id, + variant_unit: "item", + ) + variant = product.variants.first + distributor = create( + :distributor_enterprise, + address: create(:address, zipcode: "6666"), + ) + line_item = build( + :line_item, + variant:, + quantity: 2, + price: 3, + ) + order_cycle = create( + :order_cycle, + suppliers: [supplier], + distributors: [distributor], + ) + order_cycle.exchanges.incoming.first.variants << variant + order_cycle.exchanges.outgoing.first.variants << variant + create( + :order, + order_cycle:, + distributor:, + line_items: [line_item], + ) + end + end +end diff --git a/swagger/dfc.yaml b/swagger/dfc.yaml index d8d0cd8ed6b..d302047b7c1 100644 --- a/swagger/dfc.yaml +++ b/swagger/dfc.yaml @@ -83,9 +83,17 @@ paths: test_example: value: "@context": https://www.datafoodconsortium.org - "@id": http://test.host/api/dfc/persons/10000 + "@id": http://test.host/api/dfc/affiliate_sales_data "@type": dfc-b:Person dfc-b:logo: '' + dfc-b:firstName: '' + dfc-b:familyName: '' + dfc-b:affiliates: + "@type": dfc-b:Enterprise + dfc-b:logo: '' + dfc-b:name: '' + dfc-b:hasDescription: '' + dfc-b:VATnumber: '' "/api/dfc/enterprises/{enterprise_id}/catalog_items": parameters: - name: enterprise_id From bd1611630fc6e3aaf29c0fb47dae3801cf49b415 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 30 Aug 2024 09:48:14 +1000 Subject: [PATCH 3/5] Build DFC data for sales --- .../affiliate_sales_data_controller.rb | 2 +- .../services/affiliate_sales_data_builder.rb | 9 +- .../affiliate_sales_data_row_builder.rb | 98 +++++++++++++++++++ .../app/services/affiliate_sales_query.rb | 85 ++++++++++++++++ .../services/quantitative_value_builder.rb | 6 +- engines/dfc_provider/lib/dfc_provider.rb | 1 + .../lib/dfc_provider/coordination.rb | 28 ++++++ .../affiliate_sales_data_builder_spec.rb | 97 +++++++++++------- .../services/affiliate_sales_query_spec.rb | 32 ++++++ swagger/dfc.yaml | 6 -- 10 files changed, 317 insertions(+), 47 deletions(-) create mode 100644 engines/dfc_provider/app/services/affiliate_sales_data_row_builder.rb create mode 100644 engines/dfc_provider/app/services/affiliate_sales_query.rb create mode 100644 engines/dfc_provider/lib/dfc_provider/coordination.rb create mode 100644 engines/dfc_provider/spec/services/affiliate_sales_query_spec.rb diff --git a/engines/dfc_provider/app/controllers/dfc_provider/affiliate_sales_data_controller.rb b/engines/dfc_provider/app/controllers/dfc_provider/affiliate_sales_data_controller.rb index 73b09af76cb..1e316f37b56 100644 --- a/engines/dfc_provider/app/controllers/dfc_provider/affiliate_sales_data_controller.rb +++ b/engines/dfc_provider/app/controllers/dfc_provider/affiliate_sales_data_controller.rb @@ -4,7 +4,7 @@ module DfcProvider # Aggregates anonymised sales data for a research project. class AffiliateSalesDataController < DfcProvider::ApplicationController def show - person = AffiliateSalesDataBuilder.person + person = AffiliateSalesDataBuilder.person(current_user) render json: DfcIo.export(person) end diff --git a/engines/dfc_provider/app/services/affiliate_sales_data_builder.rb b/engines/dfc_provider/app/services/affiliate_sales_data_builder.rb index 1b2e99ec3c2..74a403a2f8f 100644 --- a/engines/dfc_provider/app/services/affiliate_sales_data_builder.rb +++ b/engines/dfc_provider/app/services/affiliate_sales_data_builder.rb @@ -2,10 +2,17 @@ class AffiliateSalesDataBuilder < DfcBuilder class << self - def person + def person(user) DataFoodConsortium::Connector::Person.new( urls.affiliate_sales_data_url, + affiliatedOrganizations: enterprises(user.enterprises) ) end + + def enterprises(enterprises) + AffiliateSalesQuery.data(enterprises).map do |row| + AffiliateSalesDataRowBuilder.new(row).build_supplier + end + end end end diff --git a/engines/dfc_provider/app/services/affiliate_sales_data_row_builder.rb b/engines/dfc_provider/app/services/affiliate_sales_data_row_builder.rb new file mode 100644 index 00000000000..073097a04f9 --- /dev/null +++ b/engines/dfc_provider/app/services/affiliate_sales_data_row_builder.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +# Represents a single row of the aggregated sales data. +class AffiliateSalesDataRowBuilder < DfcBuilder + attr_reader :item + + def initialize(row) + super() + @item = AffiliateSalesQuery.label_row(row) + end + + def build_supplier + DataFoodConsortium::Connector::Enterprise.new( + nil, + localizations: [build_address(item[:supplier_postcode])], + suppliedProducts: [build_product], + ) + end + + def build_distributor + DataFoodConsortium::Connector::Enterprise.new( + nil, + localizations: [build_address(item[:distributor_postcode])], + ) + end + + def build_product + DataFoodConsortium::Connector::SuppliedProduct.new( + nil, + name: item[:product_name], + quantity: build_product_quantity, + ).tap do |product| + product.registerSemanticProperty("dfc-b:concernedBy") { + build_order_line + } + end + end + + def build_order_line + DataFoodConsortium::Connector::OrderLine.new( + nil, + quantity: build_line_quantity, + price: build_price, + order: build_order, + ) + end + + def build_order + DataFoodConsortium::Connector::Order.new( + nil, + saleSession: build_sale_session, + ) + end + + def build_sale_session + DataFoodConsortium::Connector::SaleSession.new( + nil, + ).tap do |session| + session.registerSemanticProperty("dfc-b:objectOf") { + build_coordination + } + end + end + + def build_coordination + DfcProvider::Coordination.new( + nil, + coordinator: build_distributor, + ) + end + + def build_product_quantity + DataFoodConsortium::Connector::QuantitativeValue.new( + unit: QuantitativeValueBuilder.unit(item[:unit_type]), + value: item[:units]&.to_f, + ) + end + + def build_line_quantity + DataFoodConsortium::Connector::QuantitativeValue.new( + unit: DfcLoader.connector.MEASURES.PIECE, + value: item[:quantity_sold]&.to_f, + ) + end + + def build_price + DataFoodConsortium::Connector::QuantitativeValue.new( + value: item[:price]&.to_f, + ) + end + + def build_address(postcode) + DataFoodConsortium::Connector::Address.new( + nil, + postalCode: postcode, + ) + end +end diff --git a/engines/dfc_provider/app/services/affiliate_sales_query.rb b/engines/dfc_provider/app/services/affiliate_sales_query.rb new file mode 100644 index 00000000000..f85333f4d8f --- /dev/null +++ b/engines/dfc_provider/app/services/affiliate_sales_query.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +class AffiliateSalesQuery + class << self + def data(enterprises) + Spree::LineItem + .joins(tables) + .where( + spree_orders: { state: "complete", distributor_id: enterprises }, + ) + .group(key_fields) + .pluck(fields) + end + + # Create a hash with labels for an array of data points: + # + # { product_name: "Apple", ... } + def label_row(row) + labels.zip(row).to_h + end + + private + + # We want to collect a lot of data from only a few columns. + # It's more efficient with `pluck`. But therefore we need well named + # tables and columns, especially because we are going to join some tables + # twice for different columns. For example the distributer postcode and + # the supplier postcode. That's why we need SQL here instead of nice Rails + # associations. + def tables + <<~SQL.squish + JOIN spree_variants ON spree_variants.id = spree_line_items.variant_id + JOIN spree_products ON spree_products.id = spree_variants.product_id + JOIN enterprises AS suppliers ON suppliers.id = spree_variants.supplier_id + JOIN spree_addresses AS supplier_addresses ON supplier_addresses.id = suppliers.address_id + JOIN spree_orders ON spree_orders.id = spree_line_items.order_id + JOIN enterprises AS distributors ON distributors.id = spree_orders.distributor_id + JOIN spree_addresses AS distributor_addresses ON distributor_addresses.id = distributors.address_id + SQL + end + + def fields + <<~SQL.squish + spree_products.name AS product_name, + spree_variants.display_name AS unit_name, + spree_products.variant_unit AS unit_type, + spree_variants.unit_value AS units, + spree_variants.unit_presentation, + spree_line_items.price, + distributor_addresses.zipcode AS distributor_postcode, + supplier_addresses.zipcode AS supplier_postcode, + + SUM(spree_line_items.quantity) AS quantity_sold + SQL + end + + def key_fields + <<~SQL.squish + product_name, + unit_name, + unit_type, + units, + spree_variants.unit_presentation, + spree_line_items.price, + distributor_postcode, + supplier_postcode + SQL + end + + # A list of column names as symbols to be used as hash keys. + def labels + %i[ + product_name + unit_name + unit_type + units + unit_presentation + price + distributor_postcode + supplier_postcode + quantity_sold + ] + end + end +end diff --git a/engines/dfc_provider/app/services/quantitative_value_builder.rb b/engines/dfc_provider/app/services/quantitative_value_builder.rb index 6baac1eecf9..ecbc0f4c59f 100644 --- a/engines/dfc_provider/app/services/quantitative_value_builder.rb +++ b/engines/dfc_provider/app/services/quantitative_value_builder.rb @@ -11,13 +11,13 @@ class QuantitativeValueBuilder < DfcBuilder def self.quantity(variant) DataFoodConsortium::Connector::QuantitativeValue.new( - unit: unit(variant), + unit: unit(variant.product.variant_unit), value: variant.unit_value, ) end - def self.unit(variant) - case variant.product.variant_unit + def self.unit(unit_name) + case unit_name when "volume" DfcLoader.connector.MEASURES.LITRE when "weight" diff --git a/engines/dfc_provider/lib/dfc_provider.rb b/engines/dfc_provider/lib/dfc_provider.rb index 526cceb9c38..51f3ec2ef41 100644 --- a/engines/dfc_provider/lib/dfc_provider.rb +++ b/engines/dfc_provider/lib/dfc_provider.rb @@ -9,6 +9,7 @@ # Custom data types require "dfc_provider/supplied_product" require "dfc_provider/address" +require "dfc_provider/coordination" module DfcProvider DataFoodConsortium::Connector::Importer.register_type(SuppliedProduct) diff --git a/engines/dfc_provider/lib/dfc_provider/coordination.rb b/engines/dfc_provider/lib/dfc_provider/coordination.rb new file mode 100644 index 00000000000..49b16aa97a5 --- /dev/null +++ b/engines/dfc_provider/lib/dfc_provider/coordination.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +if defined? DataFoodConsortium::Connector::Coordination + ActiveSupport::Deprecation.warn <<~TEXT + DataFoodConsortium::Connector::Coordination is now available. + Please replace your own implementation with the official class. + TEXT +end + +module DfcProvider + class Coordination + include VirtualAssembly::Semantizer::SemanticObject + + SEMANTIC_TYPE = "dfc-b:Coordination" + + attr_accessor :coordinator + + def initialize(semantic_id, coordinator: nil) + super(semantic_id) + + self.semanticType = SEMANTIC_TYPE + + @coordinator = coordinator + registerSemanticProperty("dfc-b:coordinatedBy", &method("coordinator")) + .valueSetter = method("coordinator=") + end + end +end diff --git a/engines/dfc_provider/spec/services/affiliate_sales_data_builder_spec.rb b/engines/dfc_provider/spec/services/affiliate_sales_data_builder_spec.rb index cd8ef1cc544..123b575d442 100644 --- a/engines/dfc_provider/spec/services/affiliate_sales_data_builder_spec.rb +++ b/engines/dfc_provider/spec/services/affiliate_sales_data_builder_spec.rb @@ -13,42 +13,67 @@ expect(person.semanticId).to eq "http://test.host/api/dfc/affiliate_sales_data" end - it "returns required sales data" do - supplier = create( - :supplier_enterprise, - owner: user, - users: [user], - address: create(:address, zipcode: "5555"), - ) - product = create( - :product, - supplier_id: supplier.id, - variant_unit: "item", - ) - variant = product.variants.first - distributor = create( - :distributor_enterprise, - address: create(:address, zipcode: "6666"), - ) - line_item = build( - :line_item, - variant:, - quantity: 2, - price: 3, - ) - order_cycle = create( - :order_cycle, - suppliers: [supplier], - distributors: [distributor], - ) - order_cycle.exchanges.incoming.first.variants << variant - order_cycle.exchanges.outgoing.first.variants << variant - create( - :order, - order_cycle:, - distributor:, - line_items: [line_item], - ) + describe "with sales data" do + before do + supplier = create( + :supplier_enterprise, + owner: user, + users: [user], + address: create(:address, zipcode: "5555"), + ) + product = create( + :product, + name: "Pomme", + supplier_id: supplier.id, + variant_unit: "item", + ) + variant = product.variants.first + distributor = create( + :distributor_enterprise, + owner: user, + address: create(:address, zipcode: "6666"), + ) + line_item = build( + :line_item, + variant:, + quantity: 2, + price: 3, + ) + order_cycle = create( + :order_cycle, + suppliers: [supplier], + distributors: [distributor], + ) + order_cycle.exchanges.incoming.first.variants << variant + order_cycle.exchanges.outgoing.first.variants << variant + create( + :order, + state: "complete", + order_cycle:, + distributor:, + line_items: [line_item], + ) + end + + it "returns required sales data" do + supplier = person.affiliatedOrganizations[0] + product = supplier.suppliedProducts[0] + line = product.semanticPropertyValue("dfc-b:concernedBy") + session = line.order.saleSession + coordination = session.semanticPropertyValue("dfc-b:objectOf") + distributor = coordination.coordinator + + expect(supplier.localizations[0].postalCode).to eq "5555" + expect(distributor.localizations[0].postalCode).to eq "6666" + + expect(product.name).to eq "Pomme" + expect(product.quantity.unit).to eq DfcLoader.connector.MEASURES.PIECE + expect(product.quantity.value).to eq 1 + + expect(line.quantity.unit).to eq DfcLoader.connector.MEASURES.PIECE + expect(line.quantity.value).to eq 2 + expect(line.price.value).to eq 3 + end end end end diff --git a/engines/dfc_provider/spec/services/affiliate_sales_query_spec.rb b/engines/dfc_provider/spec/services/affiliate_sales_query_spec.rb new file mode 100644 index 00000000000..1cadb161107 --- /dev/null +++ b/engines/dfc_provider/spec/services/affiliate_sales_query_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +RSpec.describe AffiliateSalesQuery do + subject(:query) { described_class } + + describe ".label_row" do + it "converts an array to a hash" do + row = [ + "Apples", + "item", "item", nil, nil, + 15.50, + "3210", "3211", + 3, + ] + expect(query.label_row(row)).to eq( + { + product_name: "Apples", + unit_name: "item", + unit_type: "item", + units: nil, + unit_presentation: nil, + price: 15.50, + distributor_postcode: "3210", + supplier_postcode: "3211", + quantity_sold: 3, + } + ) + end + end +end diff --git a/swagger/dfc.yaml b/swagger/dfc.yaml index d302047b7c1..3b1e9042257 100644 --- a/swagger/dfc.yaml +++ b/swagger/dfc.yaml @@ -88,12 +88,6 @@ paths: dfc-b:logo: '' dfc-b:firstName: '' dfc-b:familyName: '' - dfc-b:affiliates: - "@type": dfc-b:Enterprise - dfc-b:logo: '' - dfc-b:name: '' - dfc-b:hasDescription: '' - dfc-b:VATnumber: '' "/api/dfc/enterprises/{enterprise_id}/catalog_items": parameters: - name: enterprise_id From 1016656781e735af3ae17b864a4e881fd0699316 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 30 Aug 2024 10:43:20 +1000 Subject: [PATCH 4/5] Publish data only of participating distributors --- .../dfc_provider/app/services/affiliate_sales_data_builder.rb | 2 +- .../spec/services/affiliate_sales_data_builder_spec.rb | 3 ++- lib/open_food_network/feature_toggle.rb | 4 ++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/engines/dfc_provider/app/services/affiliate_sales_data_builder.rb b/engines/dfc_provider/app/services/affiliate_sales_data_builder.rb index 74a403a2f8f..652a83f640d 100644 --- a/engines/dfc_provider/app/services/affiliate_sales_data_builder.rb +++ b/engines/dfc_provider/app/services/affiliate_sales_data_builder.rb @@ -5,7 +5,7 @@ class << self def person(user) DataFoodConsortium::Connector::Person.new( urls.affiliate_sales_data_url, - affiliatedOrganizations: enterprises(user.enterprises) + affiliatedOrganizations: enterprises(user.affiliate_enterprises) ) end diff --git a/engines/dfc_provider/spec/services/affiliate_sales_data_builder_spec.rb b/engines/dfc_provider/spec/services/affiliate_sales_data_builder_spec.rb index 123b575d442..9673b70045f 100644 --- a/engines/dfc_provider/spec/services/affiliate_sales_data_builder_spec.rb +++ b/engines/dfc_provider/spec/services/affiliate_sales_data_builder_spec.rb @@ -33,6 +33,7 @@ owner: user, address: create(:address, zipcode: "6666"), ) + ConnectedApps::AffiliateSalesData.new(enterprise: distributor).connect({}) line_item = build( :line_item, variant:, @@ -55,7 +56,7 @@ ) end - it "returns required sales data" do + it "returns required sales data", feature: :affiliate_sales_data do supplier = person.affiliatedOrganizations[0] product = supplier.suppliedProducts[0] line = product.semanticPropertyValue("dfc-b:concernedBy") diff --git a/lib/open_food_network/feature_toggle.rb b/lib/open_food_network/feature_toggle.rb index aedef176fb9..a3b1d8b2537 100644 --- a/lib/open_food_network/feature_toggle.rb +++ b/lib/open_food_network/feature_toggle.rb @@ -44,6 +44,10 @@ module FeatureToggle Enterprise data can be shared with another app. The first example is the Australian Discover Regenerative Portal. DESC + "affiliate_sales_data" => <<~DESC, + Activated for a user. + The user (INRAE researcher) has access to anonymised sales. + DESC }.freeze # Features you would like to be enabled to start with. From d52134dad846210885af6c3f70184e830a4db4d3 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Fri, 30 Aug 2024 14:30:18 +1000 Subject: [PATCH 5/5] Filter sales data by dates --- .../affiliate_sales_data_controller.rb | 19 ++++- .../services/affiliate_sales_data_builder.rb | 15 ++-- .../app/services/affiliate_sales_query.rb | 9 ++- .../requests/affiliate_sales_data_spec.rb | 49 ++++++++++-- .../services/affiliate_sales_query_spec.rb | 37 ++++++++++ swagger/dfc.yaml | 74 +++++++++++++++++++ 6 files changed, 186 insertions(+), 17 deletions(-) diff --git a/engines/dfc_provider/app/controllers/dfc_provider/affiliate_sales_data_controller.rb b/engines/dfc_provider/app/controllers/dfc_provider/affiliate_sales_data_controller.rb index 1e316f37b56..bff74473015 100644 --- a/engines/dfc_provider/app/controllers/dfc_provider/affiliate_sales_data_controller.rb +++ b/engines/dfc_provider/app/controllers/dfc_provider/affiliate_sales_data_controller.rb @@ -3,10 +3,27 @@ module DfcProvider # Aggregates anonymised sales data for a research project. class AffiliateSalesDataController < DfcProvider::ApplicationController + rescue_from Date::Error, with: -> { head :bad_request } + def show - person = AffiliateSalesDataBuilder.person(current_user) + person = AffiliateSalesDataBuilder.person(current_user, filter_params) render json: DfcIo.export(person) end + + private + + def filter_params + { + start_date: parse_date(params[:startDate]), + end_date: parse_date(params[:endDate]), + } + end + + def parse_date(string) + return if string.blank? + + Date.parse(string) + end end end diff --git a/engines/dfc_provider/app/services/affiliate_sales_data_builder.rb b/engines/dfc_provider/app/services/affiliate_sales_data_builder.rb index 652a83f640d..26d11b3a985 100644 --- a/engines/dfc_provider/app/services/affiliate_sales_data_builder.rb +++ b/engines/dfc_provider/app/services/affiliate_sales_data_builder.rb @@ -2,17 +2,16 @@ class AffiliateSalesDataBuilder < DfcBuilder class << self - def person(user) + def person(user, filters = {}) + data = AffiliateSalesQuery.data(user.affiliate_enterprises, **filters) + suppliers = data.map do |row| + AffiliateSalesDataRowBuilder.new(row).build_supplier + end + DataFoodConsortium::Connector::Person.new( urls.affiliate_sales_data_url, - affiliatedOrganizations: enterprises(user.affiliate_enterprises) + affiliatedOrganizations: suppliers, ) end - - def enterprises(enterprises) - AffiliateSalesQuery.data(enterprises).map do |row| - AffiliateSalesDataRowBuilder.new(row).build_supplier - end - end end end diff --git a/engines/dfc_provider/app/services/affiliate_sales_query.rb b/engines/dfc_provider/app/services/affiliate_sales_query.rb index f85333f4d8f..3193ed5623f 100644 --- a/engines/dfc_provider/app/services/affiliate_sales_query.rb +++ b/engines/dfc_provider/app/services/affiliate_sales_query.rb @@ -2,11 +2,16 @@ class AffiliateSalesQuery class << self - def data(enterprises) + def data(enterprises, start_date: nil, end_date: nil) + end_date = end_date&.end_of_day # Include the whole end date. + Spree::LineItem .joins(tables) .where( - spree_orders: { state: "complete", distributor_id: enterprises }, + spree_orders: { + state: "complete", distributor_id: enterprises, + completed_at: [start_date..end_date], + }, ) .group(key_fields) .pluck(fields) diff --git a/engines/dfc_provider/spec/requests/affiliate_sales_data_spec.rb b/engines/dfc_provider/spec/requests/affiliate_sales_data_spec.rb index b8d03b881fc..3199fc4a22b 100644 --- a/engines/dfc_provider/spec/requests/affiliate_sales_data_spec.rb +++ b/engines/dfc_provider/spec/requests/affiliate_sales_data_spec.rb @@ -8,16 +8,53 @@ before { login_as user } path "/api/dfc/affiliate_sales_data" do + parameter name: :startDate, in: :query, type: :string + parameter name: :endDate, in: :query, type: :string + get "Show sales data of person's affiliate enterprises" do produces "application/json" - response "200", "successful" do - run_test! do - expect(json_response).to include( - "@id" => "http://test.host/api/dfc/affiliate_sales_data", - "@type" => "dfc-b:Person", - ) + response "200", "successful", feature: :affiliate_sales_data do + let(:startDate) { Date.yesterday } + let(:endDate) { Time.zone.today } + + before do + order = create(:order_with_totals_and_distribution, :completed) + ConnectedApps::AffiliateSalesData.new( + enterprise: order.distributor + ).connect({}) + end + + context "with date filters" do + let(:startDate) { Date.tomorrow } + let(:endDate) { Date.tomorrow } + + run_test! do + expect(json_response).to include( + "@id" => "http://test.host/api/dfc/affiliate_sales_data", + "@type" => "dfc-b:Person", + ) + + expect(json_response["dfc-b:affiliates"]).to eq nil + end end + + context "not filtered" do + run_test! do + expect(json_response).to include( + "@id" => "http://test.host/api/dfc/affiliate_sales_data", + "@type" => "dfc-b:Person", + ) + expect(json_response["dfc-b:affiliates"]).to be_present + end + end + end + + response "400", "bad request" do + let(:startDate) { "yesterday" } + let(:endDate) { "tomorrow" } + + run_test! end end end diff --git a/engines/dfc_provider/spec/services/affiliate_sales_query_spec.rb b/engines/dfc_provider/spec/services/affiliate_sales_query_spec.rb index 1cadb161107..f1ab8309772 100644 --- a/engines/dfc_provider/spec/services/affiliate_sales_query_spec.rb +++ b/engines/dfc_provider/spec/services/affiliate_sales_query_spec.rb @@ -5,6 +5,43 @@ RSpec.describe AffiliateSalesQuery do subject(:query) { described_class } + describe ".data" do + let(:order) { create(:order_with_totals_and_distribution, :completed) } + let(:today) { Time.zone.today } + let(:yesterday) { Time.zone.yesterday } + let(:tomorrow) { Time.zone.tomorrow } + + it "returns data" do + # Test data creation takes time. + # So I'm executing more tests in one `it` block here. + # And make it simpler to call the subject many times: + count_rows = lambda do |**args| + query.data(order.distributor, **args).count + end + + # Without any filters: + expect(count_rows.call).to eq 1 + + # From today: + expect(count_rows.call(start_date: today)).to eq 1 + + # Until today: + expect(count_rows.call(end_date: today)).to eq 1 + + # Just today: + expect(count_rows.call(start_date: today, end_date: today)).to eq 1 + + # Yesterday: + expect(count_rows.call(start_date: yesterday, end_date: yesterday)).to eq 0 + + # Until yesterday: + expect(count_rows.call(end_date: yesterday)).to eq 0 + + # From tomorrow: + expect(count_rows.call(start_date: tomorrow)).to eq 0 + end + end + describe ".label_row" do it "converts an array to a hash" do row = [ diff --git a/swagger/dfc.yaml b/swagger/dfc.yaml index 3b1e9042257..68e1524b1fa 100644 --- a/swagger/dfc.yaml +++ b/swagger/dfc.yaml @@ -70,6 +70,15 @@ paths: '404': description: not found "/api/dfc/affiliate_sales_data": + parameters: + - name: startDate + in: query + schema: + type: string + - name: endDate + in: query + schema: + type: string get: summary: Show sales data of person's affiliate enterprises tags: @@ -88,6 +97,71 @@ paths: dfc-b:logo: '' dfc-b:firstName: '' dfc-b:familyName: '' + dfc-b:affiliates: + "@type": dfc-b:Enterprise + dfc-b:hasAddress: + "@type": dfc-b:Address + dfc-b:hasStreet: '' + dfc-b:hasPostalCode: '20170' + dfc-b:hasCity: '' + dfc-b:hasCountry: '' + dfc-b:latitude: 0.0 + dfc-b:longitude: 0.0 + dfc-b:region: '' + dfc-b:logo: '' + dfc-b:name: '' + dfc-b:hasDescription: '' + dfc-b:VATnumber: '' + dfc-b:supplies: + "@type": dfc-b:SuppliedProduct + dfc-b:name: 'Product #3 - 7198' + dfc-b:description: '' + dfc-b:hasQuantity: + "@type": dfc-b:QuantitativeValue + dfc-b:hasUnit: dfc-m:Gram + dfc-b:value: 1.0 + dfc-b:alcoholPercentage: 0.0 + dfc-b:lifetime: '' + dfc-b:usageOrStorageCondition: '' + dfc-b:totalTheoreticalStock: 0.0 + dfc-b:concernedBy: + "@type": dfc-b:OrderLine + dfc-b:description: '' + dfc-b:quantity: + "@type": dfc-b:QuantitativeValue + dfc-b:hasUnit: dfc-m:Piece + dfc-b:value: 1.0 + dfc-b:hasPrice: + "@type": dfc-b:QuantitativeValue + dfc-b:value: 10.0 + dfc-b:partOf: + "@type": dfc-b:Order + dfc-b:orderNumber: '' + dfc-b:date: '' + dfc-b:belongsTo: + "@type": dfc-b:SaleSession + dfc-b:beginDate: '' + dfc-b:endDate: '' + dfc-b:quantity: 0.0 + dfc-b:objectOf: + "@type": dfc-b:Coordination + dfc-b:coordinatedBy: + "@type": dfc-b:Enterprise + dfc-b:hasAddress: + "@type": dfc-b:Address + dfc-b:hasStreet: '' + dfc-b:hasPostalCode: '20170' + dfc-b:hasCity: '' + dfc-b:hasCountry: '' + dfc-b:latitude: 0.0 + dfc-b:longitude: 0.0 + dfc-b:region: '' + dfc-b:logo: '' + dfc-b:name: '' + dfc-b:hasDescription: '' + dfc-b:VATnumber: '' + '400': + description: bad request "/api/dfc/enterprises/{enterprise_id}/catalog_items": parameters: - name: enterprise_id