diff --git a/app/models/crop_job_template.rb b/app/models/crop_job_template.rb index fa46f5563..126d81909 100644 --- a/app/models/crop_job_template.rb +++ b/app/models/crop_job_template.rb @@ -14,6 +14,10 @@ class CropJobTemplate < ApplicationRecord validates :name, presence: true + # Those written for the crop itself or, if it is a variety, for the crop it + # is a variety of. + scope :for_crop, ->(crop) { where(crop_id: [crop.id, crop.parent_id].compact) } + accepts_nested_attributes_for :steps, allow_destroy: true, reject_if: :all_blank def to_s diff --git a/app/services/apply_crop_job_template.rb b/app/services/apply_crop_job_template.rb new file mode 100644 index 000000000..7f2d6c4dc --- /dev/null +++ b/app/services/apply_crop_job_template.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# Makes jobs for a planting from a crop job template: one for each step that +# falls today or later. A step the planting already has a job for is not made +# again, so applying a template twice does nothing the second time. A step +# repeating weekly, fortnightly or monthly is made once, for its first day. +class ApplyCropJobTemplate + def initialize(template, planting, today: Time.zone.today) + @template = template + @planting = planting + @today = today + end + + # The jobs made, in the order of the template's steps. + def call + return [] if @planting.planted_at.blank? + + entries.filter_map { |entry| create_job(entry) } + end + + private + + def entries + schedule = CropJobSchedule.new(@template, planting_out_on: @planting.planted_at.to_date, today: @today) + schedule.entries.select { |entry| entry.date && !entry.past? && applied_step_ids.exclude?(entry.step.id) } + end + + # Steps of the template that already have a job for this planting. + def applied_step_ids + @applied_step_ids ||= Job.where(jobbable: @planting, crop_job_step: @template.steps).pluck(:crop_job_step_id) + end + + def create_job(entry) + Job.create!(name: entry.step.name, due_date: entry.date, owner: @planting.owner, garden: @planting.garden, + jobbable: @planting, crop_job_step: entry.step) + end +end diff --git a/spec/models/crop_job_template_spec.rb b/spec/models/crop_job_template_spec.rb index 36d88114e..383881376 100644 --- a/spec/models/crop_job_template_spec.rb +++ b/spec/models/crop_job_template_spec.rb @@ -43,4 +43,27 @@ describe CropJobTemplate do expect { template.destroy }.to change(CropJobStep, :count).by(-2) end end + + describe '.for_crop' do + subject(:found) { described_class.for_crop(crop) } + + let(:parent) { create(:crop) } + let(:crop) { create(:crop, parent:) } + + it 'has templates written for the crop' do + expect(found).to include(create(:crop_job_template, crop:)) + end + + it 'has templates written for its parent' do + expect(found).to include(create(:crop_job_template, crop: parent)) + end + + it 'leaves out templates for other crops' do + expect(found).not_to include(create(:crop_job_template)) + end + + it 'leaves out templates for a sibling variety' do + expect(found).not_to include(create(:crop_job_template, crop: create(:crop, parent:))) + end + end end diff --git a/spec/services/apply_crop_job_template_spec.rb b/spec/services/apply_crop_job_template_spec.rb new file mode 100644 index 000000000..3518a6a94 --- /dev/null +++ b/spec/services/apply_crop_job_template_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ApplyCropJobTemplate do + subject(:apply) { described_class.new(template, planting, today:) } + + let(:today) { Date.new(2026, 10, 23) } + let(:planting) { create(:planting, planted_at: Date.new(2026, 10, 20)) } + let(:template) { create(:crop_job_template, crop: planting.crop) } + let!(:stake) { create(:crop_job_step, crop_job_template: template, name: 'Stake', offset_amount: 2, offset_unit: 'weeks') } + + describe '#call' do + it 'makes a job for a step' do + expect(apply.call.map(&:name)).to eq ['Stake'] + end + + it 'dates the job from the planting' do + expect(apply.call.first.due_date).to eq Date.new(2026, 11, 3) + end + + it 'makes the job the planting owner\'s' do + expect(apply.call.first.owner).to eq planting.owner + end + + it 'makes the job about the planting' do + expect(apply.call.first.jobbable).to eq planting + end + + it 'puts the job in the planting\'s garden' do + expect(apply.call.first.garden).to eq planting.garden + end + + it 'records the step the job came from' do + expect(apply.call.first.crop_job_step).to eq stake + end + + context 'when applied a second time' do + before { described_class.new(template, planting, today:).call } + + it 'makes no more jobs' do + expect(apply.call).to be_empty + end + end + + context 'with a step that fell before today' do + before { create(:crop_job_step, crop_job_template: template, name: 'Harden off', offset_amount: 1, offset_unit: 'days') } + + it 'leaves it out' do + expect(apply.call.map(&:name)).not_to include 'Harden off' + end + end + + context 'with a step on the first harvest and no harvest date known' do + before { create(:crop_job_step, crop_job_template: template, name: 'Pick', anchor: 'first_harvest') } + + it 'leaves it out' do + expect(apply.call.map(&:name)).not_to include 'Pick' + end + end + + context 'with a planting that has no planting date' do + let(:planting) { create(:planting, planted_at: nil) } + + it 'makes no jobs' do + expect(apply.call).to be_empty + end + end + end +end