- Fix Oban self-scheduling chain silently dying (remove unique constraint so after block can schedule next job)
- Fix Natchez and TrueShotAmmo extract_price defaulting to 0 for missing price data
- Fix caliber_matcher redundant String.downcase inside Enum.find (pre-downcase outside loop)
- Fix runner.ex double iteration (Enum.map + Enum.count merged into single Enum.reduce)
- Fix price_stats_for_caliber firing 3 queries instead of 2 (merge all_time + thirty_day via FILTER)
- Fix PSA duplicate Floki.find on same selector (merge extract_title + extract_url)
- Fix PubSub broadcast carrying no caliber IDs (now includes IDs; clients skip irrelevant reloads)
- Fix BulkAmmo ^ anchor skipping non-leading round counts
- Fix TargetSportsUSA fragile exact text match 'Add To Cart' (use case-insensitive contains)
- Fix SgAmmo dead destructure {_, _, _} = row
- Fix text_detector brass/nickel detection order
- Fix Natchez GraphQL string interpolation (add escape_gql_string)
- Fix chart data triple Enum.map merged into single reduce
- Change Oban scraping queue workers from 2 to 1 (only one needed with Process.sleep)
191 lines
5.4 KiB
Elixir
191 lines
5.4 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)
|
|
casing = Keyword.get(opts, :casing)
|
|
grain_weight = Keyword.get(opts, :grain_weight)
|
|
subsonic = Keyword.get(opts, :subsonic)
|
|
|
|
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]
|
|
)
|
|
|> maybe_filter_product(:casing, casing)
|
|
|> maybe_filter_product(:grain_weight, grain_weight)
|
|
|> maybe_filter_product(:subsonic, subsonic)
|
|
|
|
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
|
|
|
|
defp maybe_filter_product(query, _field, nil), do: query
|
|
|
|
defp maybe_filter_product(query, :casing, value) do
|
|
where(query, [_ps, p], p.casing == ^value)
|
|
end
|
|
|
|
defp maybe_filter_product(query, :grain_weight, value) do
|
|
where(query, [_ps, p], p.grain_weight == ^value)
|
|
end
|
|
|
|
defp maybe_filter_product(query, :subsonic, value) do
|
|
where(query, [_ps, p], p.subsonic == ^value)
|
|
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
|
|
thirty_days_ago = DateTime.add(DateTime.utc_now(), -30 * 86_400, :second)
|
|
|
|
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),
|
|
thirty_day_avg:
|
|
fragment(
|
|
"round(avg(?) filter (where ? >= ?))::integer",
|
|
ps.price_per_round_cents,
|
|
ps.recorded_at,
|
|
^thirty_days_ago
|
|
)
|
|
}
|
|
)
|
|
)
|
|
|
|
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()
|
|
|
|
%{
|
|
current_min: current_min,
|
|
all_time_low: stats.all_time_low,
|
|
all_time_high: stats.all_time_high,
|
|
thirty_day_avg: stats.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
|