defmodule Mv.MembershipFees.CycleGenerationJob do @moduledoc """ Scheduled job for generating membership fee cycles. This module provides a skeleton for scheduled cycle generation. In the future, this can be integrated with Oban or similar job processing libraries. ## Current Implementation Currently provides manual execution functions that can be called: - From IEx console for administrative tasks - From a cron job via a Mix task - From the admin UI (future) ## Future Oban Integration When Oban is added to the project, this module can be converted to an Oban worker: defmodule Mv.MembershipFees.CycleGenerationJob do use Oban.Worker, queue: :membership_fees, max_attempts: 3 @impl Oban.Worker def perform(%Oban.Job{}) do Mv.MembershipFees.CycleGenerator.generate_cycles_for_all_members() end end ## Usage # Manual execution from IEx Mv.MembershipFees.CycleGenerationJob.run() # Check if cycles need to be generated Mv.MembershipFees.CycleGenerationJob.pending_members_count() """ alias Mv.MembershipFees.CycleGenerator require Ash.Query require Logger @doc """ Runs the cycle generation job for all active members. This is the main entry point for scheduled execution. ## Returns - `{:ok, results}` - Map with success/failed counts - `{:error, reason}` - Error with reason ## Examples iex> Mv.MembershipFees.CycleGenerationJob.run() {:ok, %{success: 45, failed: 0, total: 45}} """ @spec run() :: {:ok, map()} | {:error, term()} def run do Logger.info("Starting membership fee cycle generation job") start_time = System.monotonic_time(:millisecond) result = CycleGenerator.generate_cycles_for_all_members() elapsed = System.monotonic_time(:millisecond) - start_time case result do {:ok, stats} -> Logger.info( "Cycle generation completed in #{elapsed}ms: #{stats.success} success, #{stats.failed} failed, #{stats.total} total" ) result {:error, reason} -> Logger.error("Cycle generation failed: #{inspect(reason)}") result end end @doc """ Runs cycle generation with custom options. ## Options - `:today` - Override today's date (useful for testing or catch-up) - `:batch_size` - Number of members to process in parallel ## Examples # Generate cycles as if today was a specific date Mv.MembershipFees.CycleGenerationJob.run(today: ~D[2024-12-31]) # Process with smaller batch size Mv.MembershipFees.CycleGenerationJob.run(batch_size: 5) """ @spec run(keyword()) :: {:ok, map()} | {:error, term()} def run(opts) when is_list(opts) do Logger.info("Starting membership fee cycle generation job with opts: #{inspect(opts)}") start_time = System.monotonic_time(:millisecond) result = CycleGenerator.generate_cycles_for_all_members(opts) elapsed = System.monotonic_time(:millisecond) - start_time case result do {:ok, stats} -> Logger.info( "Cycle generation completed in #{elapsed}ms: #{stats.success} success, #{stats.failed} failed, #{stats.total} total" ) result {:error, reason} -> Logger.error("Cycle generation failed: #{inspect(reason)}") result end end @doc """ Returns the count of members that need cycle generation. A member needs cycle generation if: - Has a membership_fee_type assigned - Has a join_date set - Is active (no exit_date or exit_date >= today) ## Returns - `{:ok, count}` - Number of members needing generation - `{:error, reason}` - Error with reason """ @spec pending_members_count() :: {:ok, non_neg_integer()} | {:error, term()} def pending_members_count do today = Date.utc_today() query = Mv.Membership.Member |> Ash.Query.filter(not is_nil(membership_fee_type_id)) |> Ash.Query.filter(not is_nil(join_date)) |> Ash.Query.filter(is_nil(exit_date) or exit_date >= ^today) case Ash.count(query) do {:ok, count} -> {:ok, count} {:error, reason} -> {:error, reason} end end @doc """ Generates cycles for a specific member by ID. Useful for administrative tasks or manual corrections. ## Parameters - `member_id` - The UUID of the member ## Returns - `{:ok, cycles}` - List of newly created cycles - `{:error, reason}` - Error with reason """ @spec run_for_member(String.t()) :: {:ok, [map()]} | {:error, term()} def run_for_member(member_id) when is_binary(member_id) do Logger.info("Generating cycles for member #{member_id}") CycleGenerator.generate_cycles_for_member(member_id) end end