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.
149 lines
3.5 KiB
Elixir
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
|