Merge pull request #12831 from mkllnk/anonymous-orders

Share anonymised sales data on DFC API with authorised users
This commit is contained in:
Maikel
2024-09-03 10:59:30 +10:00
committed by GitHub
13 changed files with 575 additions and 3 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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"

View File

@@ -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

View File

@@ -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)

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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