ammocpr/lib/ammoprices/catalog.ex
Graham McIntire ae7e117b23
Prune listings that disappear from retailer scrapes
When a product no longer appears in a successful scrape for its
retailer+caliber, mark it out of stock. After each scrape cycle, hard
delete any product whose last_seen_at is older than 30 days (snapshots
cascade via FK). A zero-result scrape leaves existing listings alone so
a parse failure or transient site glitch can't nuke the catalog.
2026-04-22 15:41:40 -05:00

149 lines
3.5 KiB
Elixir

defmodule Ammoprices.Catalog do
@moduledoc false
import Ecto.Query
alias Ammoprices.Catalog.Caliber
alias Ammoprices.Catalog.Product
alias Ammoprices.Catalog.Retailer
alias Ammoprices.Repo
# Retailers
def list_retailers do
Repo.all(Retailer)
end
def get_retailer!(id) do
Repo.get!(Retailer, id)
end
def get_retailer_by_slug!(slug) do
Repo.get_by!(Retailer, slug: slug)
end
def create_retailer(attrs) do
%Retailer{}
|> Retailer.changeset(attrs)
|> Repo.insert()
end
def update_retailer(%Retailer{} = retailer, attrs) do
retailer
|> Retailer.changeset(attrs)
|> Repo.update()
end
# Calibers
def list_calibers do
Repo.all(Caliber)
end
def list_calibers_by_category(category) do
Caliber
|> where([c], c.category == ^category)
|> Repo.all()
end
def get_caliber!(id) do
Repo.get!(Caliber, id)
end
def get_caliber_by_slug!(slug) do
Repo.get_by!(Caliber, slug: slug)
end
def create_caliber(attrs) do
%Caliber{}
|> Caliber.changeset(attrs)
|> Repo.insert()
end
# Products
def upsert_product(retailer_id, caliber_id, attrs) do
%Product{retailer_id: retailer_id, caliber_id: caliber_id}
|> Product.changeset(attrs)
|> Repo.insert(
on_conflict:
{:replace,
[
:title,
:brand,
:grain_weight,
:round_count,
:casing,
:condition,
:in_stock,
:subsonic,
:last_seen_at,
:updated_at
]},
conflict_target: [:retailer_id, :url],
returning: true
)
end
@doc """
Marks products belonging to `retailer_id`/`caliber_id` as out of stock when
their `last_seen_at` is strictly before `cutoff`. Returns `{count, nil}`.
Used by the scrape runner to flag listings that disappeared from the
retailer's current category response.
"""
def mark_unseen_products_out_of_stock(retailer_id, caliber_id, %DateTime{} = cutoff) do
now = DateTime.truncate(DateTime.utc_now(), :second)
Product
|> where([p], p.retailer_id == ^retailer_id)
|> where([p], p.caliber_id == ^caliber_id)
|> where([p], p.last_seen_at < ^cutoff)
|> where([p], p.in_stock == true)
|> Repo.update_all(set: [in_stock: false, updated_at: now])
end
@doc """
Hard-deletes products whose `last_seen_at` is older than `cutoff`. Related
price snapshots cascade via the foreign key. Returns `{count, nil}`.
"""
def delete_stale_products(%DateTime{} = cutoff) do
Product
|> where([p], p.last_seen_at < ^cutoff)
|> Repo.delete_all()
end
def list_products_for_caliber(caliber_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
Product
|> where([p], p.caliber_id == ^caliber_id)
|> limit(^limit)
|> Repo.all()
end
def distinct_grain_weights_for_caliber(caliber_id) do
Product
|> where([p], p.caliber_id == ^caliber_id)
|> where([p], not is_nil(p.grain_weight))
|> distinct(true)
|> select([p], p.grain_weight)
|> order_by([p], asc: p.grain_weight)
|> Repo.all()
end
def has_subsonic_products?(caliber_id) do
Product
|> where([p], p.caliber_id == ^caliber_id and p.subsonic == true)
|> Repo.exists?()
end
def distinct_casings_for_caliber(caliber_id) do
Product
|> where([p], p.caliber_id == ^caliber_id)
|> where([p], not is_nil(p.casing))
|> distinct(true)
|> select([p], p.casing)
|> order_by([p], asc: p.casing)
|> Repo.all()
end
end