mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-01-24 20:36:49 +00:00
Merge pull request #12831 from mkllnk/anonymous-orders
Share anonymised sales data on DFC API with authorised users
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
90
engines/dfc_provider/app/services/affiliate_sales_query.rb
Normal file
90
engines/dfc_provider/app/services/affiliate_sales_query.rb
Normal file
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
28
engines/dfc_provider/lib/dfc_provider/coordination.rb
Normal file
28
engines/dfc_provider/lib/dfc_provider/coordination.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user