Build DFC data for sales

This commit is contained in:
Maikel Linke
2024-08-30 09:48:14 +10:00
parent ce28c10c7e
commit bd1611630f
10 changed files with 317 additions and 47 deletions

View File

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

View File

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

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

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

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

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

View File

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

View File

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