All checks were successful
continuous-integration/drone/push Build is passing
- Add CycleGenerator module with advisory lock mechanism - Add SetMembershipFeeStartDate change for auto-calculation - Extend Settings with include_joining_cycle and default_membership_fee_type_id - Add scheduled job skeleton for future Oban integration
174 lines
4.7 KiB
Elixir
174 lines
4.7 KiB
Elixir
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
|