mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2025-02-21 15:54:22 +01:00
(feat) auto cancel trainings under the threshold
This commit is contained in:
parent
0dc564a294
commit
e2edbb419a
@ -50,6 +50,24 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the inputted value before saving it in the RHF state
|
||||||
|
*/
|
||||||
|
const parseValue = (value: string) => {
|
||||||
|
if ([null, ''].includes(value) && nullable) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
if (type === 'number') {
|
||||||
|
const num: number = parseFloat(value);
|
||||||
|
if (Number.isNaN(num) && nullable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Compose classnames from props
|
// Compose classnames from props
|
||||||
const classNames = [
|
const classNames = [
|
||||||
`${className || ''}`,
|
`${className || ''}`,
|
||||||
@ -66,7 +84,7 @@ export const FormInput = <TFieldValues extends FieldValues, TInputType>({ id, re
|
|||||||
{...register(id as FieldPath<TFieldValues>, {
|
{...register(id as FieldPath<TFieldValues>, {
|
||||||
...rules,
|
...rules,
|
||||||
valueAsDate: type === 'date',
|
valueAsDate: type === 'date',
|
||||||
setValueAs: v => ([null, ''].includes(v) && nullable) ? null : (type === 'number' ? parseFloat(v) : v),
|
setValueAs: parseValue,
|
||||||
value: defaultValue as FieldPathValue<TFieldValues, FieldPath<TFieldValues>>,
|
value: defaultValue as FieldPathValue<TFieldValues, FieldPath<TFieldValues>>,
|
||||||
onChange: (e) => { handleChange(e); }
|
onChange: (e) => { handleChange(e); }
|
||||||
})}
|
})}
|
||||||
|
@ -98,12 +98,14 @@ export const TrainingsSettings: React.FC<TrainingsSettingsProps> = ({ onError, o
|
|||||||
register={register}
|
register={register}
|
||||||
rules={{ required: isActiveAutoCancellation, min: 0 }}
|
rules={{ required: isActiveAutoCancellation, min: 0 }}
|
||||||
step={1}
|
step={1}
|
||||||
|
nullable
|
||||||
formState={formState}
|
formState={formState}
|
||||||
label={t('app.admin.trainings_settings.automatic_cancellation_threshold')} />
|
label={t('app.admin.trainings_settings.automatic_cancellation_threshold')} />
|
||||||
<FormInput id="trainings_auto_cancel_deadline"
|
<FormInput id="trainings_auto_cancel_deadline"
|
||||||
type="number"
|
type="number"
|
||||||
register={register}
|
register={register}
|
||||||
rules={{ required: isActiveAutoCancellation, min: 1 }}
|
rules={{ required: isActiveAutoCancellation, min: 1 }}
|
||||||
|
nullable
|
||||||
step={1}
|
step={1}
|
||||||
formState={formState}
|
formState={formState}
|
||||||
label={t('app.admin.trainings_settings.automatic_cancellation_deadline')} />
|
label={t('app.admin.trainings_settings.automatic_cancellation_deadline')} />
|
||||||
|
@ -52,7 +52,7 @@ export const Trainings: React.FC<TrainingsProps> = ({ onError, onSuccess }) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
TrainingAPI.index({ disabled: filter })
|
TrainingAPI.index(typeof filter === 'boolean' ? { disabled: filter } : {})
|
||||||
.then(setTrainings)
|
.then(setTrainings)
|
||||||
.catch(onError);
|
.catch(onError);
|
||||||
}, [filter]);
|
}, [filter]);
|
||||||
|
@ -19,11 +19,11 @@ class Availability < ApplicationRecord
|
|||||||
has_many :spaces_availabilities, dependent: :destroy
|
has_many :spaces_availabilities, dependent: :destroy
|
||||||
has_many :spaces, through: :spaces_availabilities
|
has_many :spaces, through: :spaces_availabilities
|
||||||
|
|
||||||
has_many :slots
|
has_many :slots, dependent: :destroy
|
||||||
has_many :slots_reservations, through: :slots
|
has_many :slots_reservations, through: :slots
|
||||||
has_many :reservations, through: :slots
|
has_many :reservations, through: :slots
|
||||||
|
|
||||||
has_one :event
|
has_one :event, dependent: :destroy
|
||||||
|
|
||||||
has_many :availability_tags, dependent: :destroy
|
has_many :availability_tags, dependent: :destroy
|
||||||
has_many :tags, through: :availability_tags
|
has_many :tags, through: :availability_tags
|
||||||
@ -58,34 +58,34 @@ class Availability < ApplicationRecord
|
|||||||
def safe_destroy
|
def safe_destroy
|
||||||
case available_type
|
case available_type
|
||||||
when 'machines'
|
when 'machines'
|
||||||
reservations = Reservation.where(reservable_type: 'Machine', reservable_id: machine_ids)
|
reservations = find_reservations('Machine', machine_ids)
|
||||||
.joins(:slots)
|
|
||||||
.where('slots.availability_id = ?', id)
|
|
||||||
when 'training'
|
when 'training'
|
||||||
reservations = Reservation.where(reservable_type: 'Training', reservable_id: training_ids)
|
reservations = find_reservations('Training', training_ids)
|
||||||
.joins(:slots)
|
|
||||||
.where('slots.availability_id = ?', id)
|
|
||||||
when 'space'
|
when 'space'
|
||||||
reservations = Reservation.where(reservable_type: 'Space', reservable_id: space_ids)
|
reservations = find_reservations('Space', space_ids)
|
||||||
.joins(:slots)
|
|
||||||
.where('slots.availability_id = ?', id)
|
|
||||||
when 'event'
|
when 'event'
|
||||||
reservations = Reservation.where(reservable_type: 'Event', reservable_id: event&.id)
|
reservations = find_reservations('Event', [event&.id])
|
||||||
.joins(:slots)
|
|
||||||
.where('slots.availability_id = ?', id)
|
|
||||||
else
|
else
|
||||||
Rails.logger.warn "[safe_destroy] Availability with unknown type #{available_type}"
|
Rails.logger.warn "[safe_destroy] Availability with unknown type #{available_type}"
|
||||||
reservations = []
|
reservations = []
|
||||||
end
|
end
|
||||||
if reservations.size.zero?
|
if reservations.size.zero?
|
||||||
# this update may not call any rails callbacks, that's why we use direct SQL update
|
# this update may not call any rails callbacks, that's why we use direct SQL update
|
||||||
update_column(:destroying, true)
|
update_column(:destroying, true) # rubocop:disable Rails/SkipsModelValidations
|
||||||
destroy
|
destroy
|
||||||
else
|
else
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @param reservable_type [String]
|
||||||
|
# @param reservable_ids [Array<Integer>]
|
||||||
|
def find_reservations(reservable_type, reservable_ids)
|
||||||
|
Reservation.where(reservable_type: reservable_type, reservable_id: reservable_ids)
|
||||||
|
.joins(:slots)
|
||||||
|
.where(slots: { availability_id: id })
|
||||||
|
end
|
||||||
|
|
||||||
## compute the total number of places over the whole space availability
|
## compute the total number of places over the whole space availability
|
||||||
def available_space_places
|
def available_space_places
|
||||||
return unless available_type == 'space'
|
return unless available_type == 'space'
|
||||||
@ -97,7 +97,7 @@ class Availability < ApplicationRecord
|
|||||||
def title(filter = {})
|
def title(filter = {})
|
||||||
case available_type
|
case available_type
|
||||||
when 'machines'
|
when 'machines'
|
||||||
return machines.to_ary.delete_if { |m| !filter[:machine_ids].include?(m.id) }.map(&:name).join(' - ') if filter[:machine_ids]
|
return machines.to_ary.delete_if { |m| filter[:machine_ids].exclude?(m.id) }.map(&:name).join(' - ') if filter[:machine_ids]
|
||||||
|
|
||||||
machines.map(&:name).join(' - ')
|
machines.map(&:name).join(' - ')
|
||||||
when 'event'
|
when 'event'
|
||||||
|
@ -2,22 +2,56 @@
|
|||||||
|
|
||||||
# Provides methods for Trainings
|
# Provides methods for Trainings
|
||||||
class TrainingService
|
class TrainingService
|
||||||
def self.list(filters)
|
class << self
|
||||||
trainings = Training.includes(:training_image, :plans, :machines)
|
# @param filters [ActionController::Parameters]
|
||||||
|
def list(filters)
|
||||||
|
trainings = Training.includes(:training_image, :plans, :machines)
|
||||||
|
|
||||||
|
trainings = filter_by_disabled(trainings, filters)
|
||||||
|
trainings = filter_by_public_page(trainings, filters)
|
||||||
|
|
||||||
|
if filters[:requested_attributes].try(:include?, 'availabilities')
|
||||||
|
trainings = trainings.includes(availabilities: [slots: [reservation: [user: %i[profile trainings]]]])
|
||||||
|
.order('availabilities.start_at DESC')
|
||||||
|
end
|
||||||
|
|
||||||
|
trainings
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param training [Training]
|
||||||
|
def auto_cancel_reservation(training)
|
||||||
|
return unless training.auto_cancel
|
||||||
|
|
||||||
|
training.availabilities
|
||||||
|
.includes(slots: :slots_reservations)
|
||||||
|
.where('availabilities.start_at >= ?', DateTime.current - training.auto_cancel_deadline.hours)
|
||||||
|
.find_each do |a|
|
||||||
|
next if a.reservations.count >= training.auto_cancel_threshold
|
||||||
|
|
||||||
|
a.slots_reservations.find_each do |sr|
|
||||||
|
sr.update(canceled_at: DateTime.current)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# @param trainings [ActiveRecord::Relation<Training>]
|
||||||
|
# @param filters [ActionController::Parameters]
|
||||||
|
def filter_by_disabled(trainings, filters)
|
||||||
|
return trainings if filters[:disabled].blank?
|
||||||
|
|
||||||
if filters[:disabled].present?
|
|
||||||
state = filters[:disabled] == 'false' ? [nil, false] : true
|
state = filters[:disabled] == 'false' ? [nil, false] : true
|
||||||
trainings = trainings.where(disabled: state)
|
trainings.where(disabled: state)
|
||||||
end
|
|
||||||
if filters[:public_page].present?
|
|
||||||
state = filters[:public_page] == 'false' ? [nil, false] : true
|
|
||||||
trainings = trainings.where(public_page: state)
|
|
||||||
end
|
|
||||||
if filters[:requested_attributes].try(:include?, 'availabilities')
|
|
||||||
trainings = trainings.includes(availabilities: [slots: [reservation: [user: %i[profile trainings]]]])
|
|
||||||
.order('availabilities.start_at DESC')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
trainings
|
# @param trainings [ActiveRecord::Relation<Training>]
|
||||||
|
# @param filters [ActionController::Parameters]
|
||||||
|
def filter_by_public_page(trainings, filters)
|
||||||
|
return trainings if filters[:public_page].blank?
|
||||||
|
|
||||||
|
state = filters[:public_page] == 'false' ? [nil, false] : true
|
||||||
|
trainings.where(public_page: state)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
12
app/workers/training_auto_cancel_worker.rb
Normal file
12
app/workers/training_auto_cancel_worker.rb
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# This training will periodically check for trainings reservations to auto-cancel
|
||||||
|
class TrainingAutoCancelWorker
|
||||||
|
include Sidekiq::Worker
|
||||||
|
|
||||||
|
def perform
|
||||||
|
Training.find_each do |t|
|
||||||
|
TrainingService.auto_cancel_reservation(t)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -52,4 +52,9 @@ accounting_data:
|
|||||||
class: AccountingWorker
|
class: AccountingWorker
|
||||||
queue: default
|
queue: default
|
||||||
|
|
||||||
|
auto_cancel_tranings:
|
||||||
|
cron: "0 * * * *" # every day, every hour
|
||||||
|
class: TrainingAutoCancelWorker
|
||||||
|
queue: default
|
||||||
|
|
||||||
<%= PluginRegistry.insert_code('yml.schedule') %>
|
<%= PluginRegistry.insert_code('yml.schedule') %>
|
||||||
|
14
db/migrate/20230124094255_add_auto_cancel_to_trainings.rb
Normal file
14
db/migrate/20230124094255_add_auto_cancel_to_trainings.rb
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# From this migration, we allows auto-cancellation of trainings
|
||||||
|
# if a minimum number of user are not registred, X hours before the
|
||||||
|
# beginning of the training
|
||||||
|
class AddAutoCancelToTrainings < ActiveRecord::Migration[5.2]
|
||||||
|
def change
|
||||||
|
change_table :trainings, bulk: true do |t|
|
||||||
|
t.boolean :auto_cancel, default: false
|
||||||
|
t.integer :auto_cancel_threshold
|
||||||
|
t.integer :auto_cancel_deadline
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
10
test/fixtures/availabilities.yml
vendored
10
test/fixtures/availabilities.yml
vendored
@ -209,3 +209,13 @@ availability_21:
|
|||||||
updated_at: 2022-12-14 12:01:26.165110000 Z
|
updated_at: 2022-12-14 12:01:26.165110000 Z
|
||||||
nb_total_places: 2
|
nb_total_places: 2
|
||||||
destroying: false
|
destroying: false
|
||||||
|
|
||||||
|
availability_22:
|
||||||
|
id: 22
|
||||||
|
start_at: <%= 1.hour.from_now.strftime('%Y-%m-%d %H:%M:%S.%9N Z') %>
|
||||||
|
end_at: <%= 2.hours.from_now.strftime('%Y-%m-%d %H:%M:%S.%9N Z') %>
|
||||||
|
available_type: training
|
||||||
|
created_at: 2023-01-24 13:34:43.841240000 Z
|
||||||
|
updated_at: 2023-01-24 13:34:43.841240000 Z
|
||||||
|
nb_total_places: 5
|
||||||
|
destroying: false
|
||||||
|
8
test/fixtures/slots.yml
vendored
8
test/fixtures/slots.yml
vendored
@ -574,3 +574,11 @@ slot_133:
|
|||||||
created_at: '2022-12-14 12:01:26.165110'
|
created_at: '2022-12-14 12:01:26.165110'
|
||||||
updated_at: '2022-12-14 12:01:26.165110'
|
updated_at: '2022-12-14 12:01:26.165110'
|
||||||
availability_id: 21
|
availability_id: 21
|
||||||
|
|
||||||
|
slot_134:
|
||||||
|
id: 134
|
||||||
|
start_at: <%= 1.hour.from_now.strftime('%Y-%m-%d %H:%M:%S.%9N Z') %>
|
||||||
|
end_at: <%= 2.hours.from_now.strftime('%Y-%m-%d %H:%M:%S.%9N Z') %>
|
||||||
|
created_at: 2023-01-24 13:34:43.841240000 Z
|
||||||
|
updated_at: 2023-01-24 13:34:43.841240000 Z
|
||||||
|
availability_id: 22
|
||||||
|
8
test/fixtures/statuses.yml
vendored
8
test/fixtures/statuses.yml
vendored
@ -1,23 +1,23 @@
|
|||||||
status_1:
|
status_1:
|
||||||
id: 1
|
id: 1
|
||||||
label: new
|
name: new
|
||||||
created_at: 2023-01-16 15:25:13.744539000 Z
|
created_at: 2023-01-16 15:25:13.744539000 Z
|
||||||
updated_at: 2023-01-16 15:25:13.744539000 Z
|
updated_at: 2023-01-16 15:25:13.744539000 Z
|
||||||
|
|
||||||
status_2:
|
status_2:
|
||||||
id: 2
|
id: 2
|
||||||
label: pending
|
name: pending
|
||||||
created_at: 2023-01-16 15:25:13.744539000 Z
|
created_at: 2023-01-16 15:25:13.744539000 Z
|
||||||
updated_at: 2023-01-16 15:25:13.744539000 Z
|
updated_at: 2023-01-16 15:25:13.744539000 Z
|
||||||
|
|
||||||
status_3:
|
status_3:
|
||||||
id: 3
|
id: 3
|
||||||
label: done
|
name: done
|
||||||
created_at: 2023-01-16 15:25:13.744539000 Z
|
created_at: 2023-01-16 15:25:13.744539000 Z
|
||||||
updated_at: 2023-01-16 15:25:13.744539000 Z
|
updated_at: 2023-01-16 15:25:13.744539000 Z
|
||||||
|
|
||||||
status_4:
|
status_4:
|
||||||
id: 4
|
id: 4
|
||||||
label: disused
|
name: disused
|
||||||
created_at: 2023-01-16 15:25:13.744539000 Z
|
created_at: 2023-01-16 15:25:13.744539000 Z
|
||||||
updated_at: 2023-01-16 15:25:13.744539000 Z
|
updated_at: 2023-01-16 15:25:13.744539000 Z
|
||||||
|
7
test/fixtures/trainings_availabilities.yml
vendored
7
test/fixtures/trainings_availabilities.yml
vendored
@ -33,3 +33,10 @@ trainings_availability_5:
|
|||||||
availability_id: 20
|
availability_id: 20
|
||||||
created_at: 2020-07-22 10:09:41.841162000 Z
|
created_at: 2020-07-22 10:09:41.841162000 Z
|
||||||
updated_at: 2020-07-22 10:09:41.841162000 Z
|
updated_at: 2020-07-22 10:09:41.841162000 Z
|
||||||
|
|
||||||
|
trainings_availability_6:
|
||||||
|
id: 6
|
||||||
|
training_id: 4
|
||||||
|
availability_id: 22
|
||||||
|
created_at: 2023-01-24 13:34:43.841240000 Z
|
||||||
|
updated_at: 2023-01-24 13:34:43.841240000 Z
|
||||||
|
@ -11,7 +11,7 @@ class StatusesTest < ActionDispatch::IntegrationTest
|
|||||||
test 'create a status' do
|
test 'create a status' do
|
||||||
post '/api/statuses',
|
post '/api/statuses',
|
||||||
params: {
|
params: {
|
||||||
label: 'Open'
|
name: 'Open'
|
||||||
}.to_json,
|
}.to_json,
|
||||||
headers: default_headers
|
headers: default_headers
|
||||||
|
|
||||||
@ -24,13 +24,13 @@ class StatusesTest < ActionDispatch::IntegrationTest
|
|||||||
status = Status.where(id: res[:id]).first
|
status = Status.where(id: res[:id]).first
|
||||||
assert_not_nil status, 'status was not created in database'
|
assert_not_nil status, 'status was not created in database'
|
||||||
|
|
||||||
assert_equal 'Open', res[:label]
|
assert_equal 'Open', res[:name]
|
||||||
end
|
end
|
||||||
|
|
||||||
test 'update a status' do
|
test 'update a status' do
|
||||||
patch '/api/statuses/1',
|
patch '/api/statuses/1',
|
||||||
params: {
|
params: {
|
||||||
label: 'Done'
|
name: 'Done'
|
||||||
}.to_json,
|
}.to_json,
|
||||||
headers: default_headers
|
headers: default_headers
|
||||||
|
|
||||||
@ -41,7 +41,7 @@ class StatusesTest < ActionDispatch::IntegrationTest
|
|||||||
# Check the status was updated
|
# Check the status was updated
|
||||||
res = json_response(response.body)
|
res = json_response(response.body)
|
||||||
assert_equal 1, res[:id]
|
assert_equal 1, res[:id]
|
||||||
assert_equal 'Done', res[:label]
|
assert_equal 'Done', res[:name]
|
||||||
end
|
end
|
||||||
|
|
||||||
test 'list all statuses' do
|
test 'list all statuses' do
|
||||||
@ -57,7 +57,7 @@ class StatusesTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
test 'delete a status' do
|
test 'delete a status' do
|
||||||
status = Status.create!(label: 'Gone too soon')
|
status = Status.create!(name: 'Gone too soon')
|
||||||
delete "/api/statuses/#{status.id}"
|
delete "/api/statuses/#{status.id}"
|
||||||
assert_response :success
|
assert_response :success
|
||||||
assert_empty response.body
|
assert_empty response.body
|
||||||
|
65
test/services/training_service_test.rb
Normal file
65
test/services/training_service_test.rb
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'test_helper'
|
||||||
|
|
||||||
|
class TrainingServiceTest < ActiveSupport::TestCase
|
||||||
|
setup do
|
||||||
|
@training = Training.find(4)
|
||||||
|
@availability = Availability.find(22)
|
||||||
|
end
|
||||||
|
|
||||||
|
test 'auto cancel reservation with less reservations than the deadline' do
|
||||||
|
@training.update(auto_cancel: true, auto_cancel_threshold: 3, auto_cancel_deadline: 24)
|
||||||
|
customer = User.find(3)
|
||||||
|
slot = @availability.slots.first
|
||||||
|
r = Reservation.create!(
|
||||||
|
reservable_id: @training.id,
|
||||||
|
reservable_type: Training.name,
|
||||||
|
slots_reservations_attributes: [{ slot_id: slot.id }],
|
||||||
|
statistic_profile_id: StatisticProfile.find_by(user: customer).id
|
||||||
|
)
|
||||||
|
TrainingService.auto_cancel_reservation(@training)
|
||||||
|
r.reload
|
||||||
|
assert_not_nil r.slots_reservations.first&.canceled_at
|
||||||
|
end
|
||||||
|
|
||||||
|
test 'do not auto cancel reservation with more reservations than the deadline' do
|
||||||
|
@training.update(auto_cancel: true, auto_cancel_threshold: 3, auto_cancel_deadline: 24)
|
||||||
|
slot = @availability.slots.first
|
||||||
|
|
||||||
|
# first reservation
|
||||||
|
c1 = User.find(2)
|
||||||
|
r1 = Reservation.create!(
|
||||||
|
reservable_id: @training.id,
|
||||||
|
reservable_type: Training.name,
|
||||||
|
slots_reservations_attributes: [{ slot_id: slot.id }],
|
||||||
|
statistic_profile_id: StatisticProfile.find_by(user: c1).id
|
||||||
|
)
|
||||||
|
|
||||||
|
# second reservation
|
||||||
|
c2 = User.find(3)
|
||||||
|
r2 = Reservation.create!(
|
||||||
|
reservable_id: @training.id,
|
||||||
|
reservable_type: Training.name,
|
||||||
|
slots_reservations_attributes: [{ slot_id: slot.id }],
|
||||||
|
statistic_profile_id: StatisticProfile.find_by(user: c2).id
|
||||||
|
)
|
||||||
|
|
||||||
|
# third reservation
|
||||||
|
c3 = User.find(3)
|
||||||
|
r3 = Reservation.create!(
|
||||||
|
reservable_id: @training.id,
|
||||||
|
reservable_type: Training.name,
|
||||||
|
slots_reservations_attributes: [{ slot_id: slot.id }],
|
||||||
|
statistic_profile_id: StatisticProfile.find_by(user: c3).id
|
||||||
|
)
|
||||||
|
|
||||||
|
TrainingService.auto_cancel_reservation(@training)
|
||||||
|
r1.reload
|
||||||
|
assert_nil r1.slots_reservations.first&.canceled_at
|
||||||
|
r2.reload
|
||||||
|
assert_nil r2.slots_reservations.first&.canceled_at
|
||||||
|
r3.reload
|
||||||
|
assert_nil r3.slots_reservations.first&.canceled_at
|
||||||
|
end
|
||||||
|
end
|
Loading…
x
Reference in New Issue
Block a user