ammocpr/lib/ammoprices/prices.ex
Graham McIntire 872c94bea1
Fix bugs and performance: Oban chain, 0-cent prices, duplicate queries, regex fragility
- 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)
2026-06-21 18:14:49 -05:00

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