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