///|
/// A product-level value event for basket and affinity analysis.
pub struct ProductEvent {
customer_id : String
product_id : String
day : Int
amount : Int
quantity : Int
}
///|
pub fn ProductEvent::new(
customer_id : String,
product_id : String,
day : Int,
amount : Int,
quantity : Int,
) -> ProductEvent {
{ customer_id, product_id, day, amount, quantity }
}
///|
pub struct ProductMetric {
product_id : String
customers : Int
orders : Int
revenue : Int
units : Int
repeat_rate : Int
average_order : Int
}
///|
pub struct ProductPair {
left : String
right : String
shared_customers : Int
support_percent : Int
}
///|
fn unique_product_ids(events : Array[ProductEvent]) -> Array[String] {
let products : Array[String] = []
for event in events {
if !products.contains(event.product_id) {
products.push(event.product_id)
}
}
products.sort()
products
}
///|
fn customer_count_for_product(
events : Array[ProductEvent],
product : String,
) -> Int {
let customers : Array[String] = []
for event in events {
if event.product_id == product && !customers.contains(event.customer_id) {
customers.push(event.customer_id)
}
}
customers.length()
}
///|
/// Aggregate product revenue, units, repeat behavior and customer reach.
pub fn product_metrics(events : Array[ProductEvent]) -> Array[ProductMetric] {
unique_product_ids(events).map(product => {
let selected = events.filter(event => event.product_id == product)
let customers = customer_count_for_product(events, product)
let repeaters : Array[String] = []
for event in selected {
let count = selected
.filter(other => other.customer_id == event.customer_id)
.length()
if count > 1 && !repeaters.contains(event.customer_id) {
repeaters.push(event.customer_id)
}
}
let revenue = selected.fold(init=0, (sum, event) => sum + event.amount)
let units = selected.fold(init=0, (sum, event) => sum + event.quantity)
{
product_id: product,
customers,
orders: selected.length(),
revenue,
units,
repeat_rate: if customers == 0 {
0
} else {
repeaters.length() * 100 / customers
},
average_order: if selected.is_empty() {
0
} else {
revenue / selected.length()
},
}
})
}
///|
/// Find product pairs purchased by the same customer in the same observed basket.
pub fn product_affinity(events : Array[ProductEvent]) -> Array[ProductPair] {
let products = unique_product_ids(events)
let customers : Array[String] = []
for event in events {
if !customers.contains(event.customer_id) {
customers.push(event.customer_id)
}
}
let pairs : Array[ProductPair] = []
for left_index in 0.. {
events.exists(event => {
event.customer_id == customer && event.product_id == left
}) &&
events.exists(event => {
event.customer_id == customer && event.product_id == right
})
})
.length()
if shared > 0 {
pairs.push({
left,
right,
shared_customers: shared,
support_percent: shared * 100 / customers.length(),
})
}
}
}
pairs.sort_by((a, b) => b.shared_customers - a.shared_customers)
pairs
}
///|
/// Return the top products by a selected metric without changing source order.
pub fn top_products(
metrics : Array[ProductMetric],
limit : Int,
) -> Array[ProductMetric] {
let sorted = metrics.copy()
sorted.sort_by((left, right) => right.revenue - left.revenue)
let result : Array[ProductMetric] = []
let count = if limit < sorted.length() { limit } else { sorted.length() }
if count > 0 {
for index in 0..