Phoenix 1.8 app that scrapes ammunition retailers (Lucky Gunner, SGAmmo) for price data and displays historical price trends. - Data model: retailers, calibers, products, price snapshots - Scraper infrastructure with Req, Floki, realistic browser headers - Oban-scheduled scrape jobs (every 4h with randomized delays) - LiveView pages: homepage with category cards, caliber detail with price table, Chart.js price history, and price stats banner - 18 seeded calibers across handgun/rifle/rimfire/shotgun categories - 77 tests
172 lines
4.8 KiB
Elixir
172 lines
4.8 KiB
Elixir
defmodule Ammoprices.Prices do
|
|
@moduledoc false
|
|
import Ecto.Query
|
|
|
|
alias Ammoprices.Prices.PriceSnapshot
|
|
alias Ammoprices.Repo
|
|
|
|
def create_snapshot(product_id, attrs) do
|
|
%PriceSnapshot{product_id: product_id}
|
|
|> PriceSnapshot.changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
@doc """
|
|
Returns the most recent price snapshot per product for a caliber,
|
|
sorted by price_per_round_cents ASC.
|
|
|
|
Options:
|
|
- :in_stock - filter by in_stock status on the snapshot
|
|
- :limit - max results (default 100)
|
|
"""
|
|
def latest_prices_for_caliber(caliber_id, opts \\ []) do
|
|
limit = Keyword.get(opts, :limit, 100)
|
|
in_stock = Keyword.get(opts, :in_stock)
|
|
|
|
latest_per_product =
|
|
from(ps in PriceSnapshot,
|
|
join: p in assoc(ps, :product),
|
|
where: p.caliber_id == ^caliber_id,
|
|
distinct: ps.product_id,
|
|
order_by: [asc: ps.product_id, desc: ps.recorded_at]
|
|
)
|
|
|
|
query =
|
|
from(ps in subquery(latest_per_product),
|
|
order_by: [asc: ps.price_per_round_cents],
|
|
limit: ^limit
|
|
)
|
|
|
|
query =
|
|
if in_stock == nil do
|
|
query
|
|
else
|
|
where(query, [ps], ps.in_stock == ^in_stock)
|
|
end
|
|
|
|
Repo.all(query)
|
|
end
|
|
|
|
@doc """
|
|
Returns daily aggregates (avg, min, max) of price_per_round_cents
|
|
for in-stock products of a caliber.
|
|
|
|
Options:
|
|
- :days - number of days back (default 30)
|
|
"""
|
|
def daily_averages_for_caliber(caliber_id, opts \\ []) do
|
|
days = Keyword.get(opts, :days, 30)
|
|
since = DateTime.add(DateTime.utc_now(), -days * 86_400, :second)
|
|
|
|
Repo.all(
|
|
from(ps in PriceSnapshot,
|
|
join: p in assoc(ps, :product),
|
|
where: p.caliber_id == ^caliber_id,
|
|
where: ps.recorded_at >= ^since,
|
|
group_by: fragment("date_trunc('day', ?)", ps.recorded_at),
|
|
order_by: [asc: fragment("date_trunc('day', ?)", ps.recorded_at)],
|
|
select: %{
|
|
date: fragment("date_trunc('day', ?)::date", ps.recorded_at),
|
|
avg_ppr: fragment("round(avg(?))::integer", ps.price_per_round_cents),
|
|
min_ppr: min(ps.price_per_round_cents),
|
|
max_ppr: max(ps.price_per_round_cents)
|
|
}
|
|
)
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Returns all snapshots for a product within a date range.
|
|
|
|
Options:
|
|
- :days - number of days back (default 30)
|
|
"""
|
|
def price_history_for_product(product_id, opts \\ []) do
|
|
days = Keyword.get(opts, :days, 30)
|
|
since = DateTime.add(DateTime.utc_now(), -days * 86_400, :second)
|
|
|
|
Repo.all(
|
|
from(ps in PriceSnapshot,
|
|
where: ps.product_id == ^product_id,
|
|
where: ps.recorded_at >= ^since,
|
|
order_by: [asc: ps.recorded_at]
|
|
)
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Returns price statistics for a caliber: current_min, all_time_low, all_time_high.
|
|
Based on in-stock snapshots only.
|
|
"""
|
|
def price_stats_for_caliber(caliber_id) do
|
|
stats =
|
|
Repo.one(
|
|
from(ps in PriceSnapshot,
|
|
join: p in assoc(ps, :product),
|
|
where: p.caliber_id == ^caliber_id,
|
|
where: ps.in_stock == true,
|
|
select: %{all_time_low: min(ps.price_per_round_cents), all_time_high: max(ps.price_per_round_cents)}
|
|
)
|
|
)
|
|
|
|
current_min =
|
|
from(ps in PriceSnapshot,
|
|
join: p in assoc(ps, :product),
|
|
where: p.caliber_id == ^caliber_id,
|
|
where: ps.in_stock == true,
|
|
distinct: ps.product_id,
|
|
order_by: [asc: ps.product_id, desc: ps.recorded_at],
|
|
select: ps.price_per_round_cents
|
|
)
|
|
|> subquery()
|
|
|> select([s], min(s.price_per_round_cents))
|
|
|> Repo.one()
|
|
|
|
thirty_days_ago = DateTime.add(DateTime.utc_now(), -30 * 86_400, :second)
|
|
|
|
thirty_day_avg =
|
|
Repo.one(
|
|
from(ps in PriceSnapshot,
|
|
join: p in assoc(ps, :product),
|
|
where: p.caliber_id == ^caliber_id,
|
|
where: ps.in_stock == true,
|
|
where: ps.recorded_at >= ^thirty_days_ago,
|
|
select: fragment("round(avg(?))::integer", ps.price_per_round_cents)
|
|
)
|
|
)
|
|
|
|
%{
|
|
current_min: current_min,
|
|
all_time_low: stats.all_time_low,
|
|
all_time_high: stats.all_time_high,
|
|
thirty_day_avg: thirty_day_avg
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Returns the minimum price_per_round_cents for each caliber,
|
|
based on the most recent in-stock snapshots.
|
|
"""
|
|
def cheapest_per_caliber do
|
|
latest =
|
|
from(ps in PriceSnapshot,
|
|
join: p in assoc(ps, :product),
|
|
where: ps.in_stock == true,
|
|
where: p.in_stock == true,
|
|
distinct: ps.product_id,
|
|
order_by: [asc: ps.product_id, desc: ps.recorded_at],
|
|
select: %{
|
|
product_id: ps.product_id,
|
|
caliber_id: p.caliber_id,
|
|
price_per_round_cents: ps.price_per_round_cents
|
|
}
|
|
)
|
|
|
|
Repo.all(
|
|
from(s in subquery(latest),
|
|
group_by: s.caliber_id,
|
|
select: %{caliber_id: s.caliber_id, min_ppr: min(s.price_per_round_cents)}
|
|
)
|
|
)
|
|
end
|
|
end
|