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 0000000000..be26749cbd --- /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 5bde8f23ba..8fc0850729 100644 --- a/engines/dfc_provider/config/routes.rb +++ b/engines/dfc_provider/config/routes.rb @@ -12,4 +12,6 @@ DfcProvider::Engine.routes.draw do 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 0000000000..08fc18817f --- /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 cc6fa07771..d8d0cd8ed6 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 be26749cbd..73b09af76c 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 0000000000..1b2e99ec3c --- /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 08fc18817f..b8d03b881f 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 @@ RSpec.describe "AffiliateSalesData", swagger_doc: "dfc.yaml", rswag_autodoc: tru 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 0000000000..cd8ef1cc54 --- /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 d8d0cd8ed6..d302047b7c 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 73b09af76c..1e316f37b5 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 1b2e99ec3c..74a403a2f8 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 0000000000..073097a04f --- /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 0000000000..f85333f4d8 --- /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 6baac1eecf..ecbc0f4c59 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 526cceb9c3..51f3ec2ef4 100644 --- a/engines/dfc_provider/lib/dfc_provider.rb +++ b/engines/dfc_provider/lib/dfc_provider.rb @@ -9,6 +9,7 @@ require "dfc_provider/engine" # 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 0000000000..49b16aa97a --- /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 cd8ef1cc54..123b575d44 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 @@ RSpec.describe AffiliateSalesDataBuilder do 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 0000000000..1cadb16110 --- /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 d302047b7c..3b1e904225 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 74a403a2f8..652a83f640 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 AffiliateSalesDataBuilder < DfcBuilder 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 123b575d44..9673b70045 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 @@ RSpec.describe AffiliateSalesDataBuilder do owner: user, address: create(:address, zipcode: "6666"), ) + ConnectedApps::AffiliateSalesData.new(enterprise: distributor).connect({}) line_item = build( :line_item, variant:, @@ -55,7 +56,7 @@ RSpec.describe AffiliateSalesDataBuilder do ) 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 aedef176fb..a3b1d8b253 100644 --- a/lib/open_food_network/feature_toggle.rb +++ b/lib/open_food_network/feature_toggle.rb @@ -44,6 +44,10 @@ module OpenFoodNetwork 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 | 17 ++--- .../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, 187 insertions(+), 18 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 1e316f37b5..bff7447301 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 652a83f640..26d11b3a98 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) - DataFoodConsortium::Connector::Person.new( - urls.affiliate_sales_data_url, - affiliatedOrganizations: enterprises(user.affiliate_enterprises) - ) - end - - def enterprises(enterprises) - AffiliateSalesQuery.data(enterprises).map do |row| + 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: suppliers, + ) 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 f85333f4d8..3193ed5623 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 b8d03b881f..3199fc4a22 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 @@ RSpec.describe "AffiliateSalesData", swagger_doc: "dfc.yaml", rswag_autodoc: tru 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 1cadb16110..f1ab830977 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 @@ require_relative "../spec_helper" 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 3b1e904225..68e1524b1f 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