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..bff7447301 --- /dev/null +++ b/engines/dfc_provider/app/controllers/dfc_provider/affiliate_sales_data_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +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, 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 new file mode 100644 index 0000000000..26d11b3a98 --- /dev/null +++ b/engines/dfc_provider/app/services/affiliate_sales_data_builder.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AffiliateSalesDataBuilder < DfcBuilder + class << self + 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_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..3193ed5623 --- /dev/null +++ b/engines/dfc_provider/app/services/affiliate_sales_query.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +class AffiliateSalesQuery + class << self + 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, + completed_at: [start_date..end_date], + }, + ) + .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/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/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/requests/affiliate_sales_data_spec.rb b/engines/dfc_provider/spec/requests/affiliate_sales_data_spec.rb new file mode 100644 index 0000000000..3199fc4a22 --- /dev/null +++ b/engines/dfc_provider/spec/requests/affiliate_sales_data_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require_relative "../swagger_helper" + +RSpec.describe "AffiliateSalesData", swagger_doc: "dfc.yaml", rswag_autodoc: true do + let(:user) { create(:oidc_user) } + + 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", 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 +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..9673b70045 --- /dev/null +++ b/engines/dfc_provider/spec/services/affiliate_sales_data_builder_spec.rb @@ -0,0 +1,80 @@ +# 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 + + 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"), + ) + ConnectedApps::AffiliateSalesData.new(enterprise: distributor).connect({}) + 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", feature: :affiliate_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..f1ab830977 --- /dev/null +++ b/engines/dfc_provider/spec/services/affiliate_sales_query_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +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 = [ + "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/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. diff --git a/swagger/dfc.yaml b/swagger/dfc.yaml index cc6fa07771..68e1524b1f 100644 --- a/swagger/dfc.yaml +++ b/swagger/dfc.yaml @@ -69,6 +69,99 @@ paths: dfc-b:region: Victoria '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: + - AffiliateSalesData + responses: + '200': + description: successful + content: + application/json: + examples: + test_example: + value: + "@context": https://www.datafoodconsortium.org + "@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: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