From 660806aeeba5274b1a2d5f20145d805de502fe2d Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 9 Jun 2026 13:16:44 +0200 Subject: [PATCH] Show overbooking warning for a user --- .../form_component.html.erb | 38 ----- .../outside_dates_step/form_component.rb | 78 ---------- .../footer_component.rb | 7 +- .../warning_step/form_component.html.erb | 90 ++++++++++++ .../warning_step/form_component.rb | 136 ++++++++++++++++++ .../resource_allocations_controller.rb | 60 +++++++- .../resource_allocations/overbooked_range.rb | 3 + .../resource_allocations/availability.rb | 27 +++- .../overbooking_analysis.rb | 4 +- .../resource_management/config/locales/en.yml | 7 + .../requests/resource_allocations_spec.rb | 62 ++++++++ .../resource_allocations/availability_spec.rb | 44 ++++++ .../overbooking_analysis_spec.rb | 5 + 13 files changed, 434 insertions(+), 127 deletions(-) delete mode 100644 modules/resource_management/app/components/resource_allocations/outside_dates_step/form_component.html.erb delete mode 100644 modules/resource_management/app/components/resource_allocations/outside_dates_step/form_component.rb rename modules/resource_management/app/components/resource_allocations/{outside_dates_step => warning_step}/footer_component.rb (96%) create mode 100644 modules/resource_management/app/components/resource_allocations/warning_step/form_component.html.erb create mode 100644 modules/resource_management/app/components/resource_allocations/warning_step/form_component.rb diff --git a/modules/resource_management/app/components/resource_allocations/outside_dates_step/form_component.html.erb b/modules/resource_management/app/components/resource_allocations/outside_dates_step/form_component.html.erb deleted file mode 100644 index da2d780be3d..00000000000 --- a/modules/resource_management/app/components/resource_allocations/outside_dates_step/form_component.html.erb +++ /dev/null @@ -1,38 +0,0 @@ -<%= - component_wrapper do - flex_layout(align_items: :center, text_align: :center, p: 3) do |body| - body.with_row(mb: 2) do - render(Primer::Beta::Octicon.new(:calendar, size: :medium, color: :danger)) - end - - body.with_row(mb: 2) do - render(Primer::Beta::Heading.new(tag: :h2, font_size: 4)) { heading } - end - - body.with_row(mb: 1) do - render(Primer::Beta::Text.new(tag: :p, color: :muted, m: 0)) { description } - end - - body.with_row do - render(Primer::Beta::Text.new(tag: :p, color: :muted, m: 0)) { confirmation } - end - - body.with_row do - form_with( - url: project_resource_allocations_path(@project), - method: :post, - id: ResourceAllocations::NewDialogComponent::FORM_ID, - data: { turbo_stream: true } - ) do - safe_join( - [ - hidden_field_tag("allocation_kind", @allocation_kind), - (hidden_field_tag("filters", @filters) if @filters.present?), - *@form_values.map { |name, value| hidden_field_tag("resource_allocation[#{name}]", value) } - ].compact - ) - end - end - end - end -%> diff --git a/modules/resource_management/app/components/resource_allocations/outside_dates_step/form_component.rb b/modules/resource_management/app/components/resource_allocations/outside_dates_step/form_component.rb deleted file mode 100644 index 3e029a18505..00000000000 --- a/modules/resource_management/app/components/resource_allocations/outside_dates_step/form_component.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) the OpenProject GmbH -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License version 3. -# -# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -# Copyright (C) 2006-2013 Jean-Philippe Lang -# Copyright (C) 2010-2013 the ChiliProject Team -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# See COPYRIGHT and LICENSE files for more details. -#++ - -module ResourceAllocations - module OutsideDatesStep - class FormComponent < ApplicationComponent - include ApplicationHelper - include OpTurbo::Streamable - include OpPrimer::ComponentHelpers - - def initialize(allocation:, project:, allocation_kind:, form_values:, filters: nil) - super - @allocation = allocation - @project = project - @allocation_kind = allocation_kind - @form_values = form_values - @filters = filters - end - - def wrapper_key - ResourceAllocations::NewDialogComponent::BODY_ID - end - - private - - def heading - I18n.t("resource_management.allocate_resource_dialog.outside_dates.title") - end - - def description - I18n.t( - "resource_management.allocate_resource_dialog.outside_dates.description", - resource_dates: date_range(@allocation.start_date, @allocation.end_date), - work_package_dates: date_range(@allocation.entity_start_date, @allocation.entity_due_date) - ) - end - - def confirmation - I18n.t("resource_management.allocate_resource_dialog.outside_dates.confirm_#{@allocation.schedule_violation}") - end - - def date_range(from_date, to_date) - "#{format_or_dash(from_date)} - #{format_or_dash(to_date)}" - end - - def format_or_dash(date) - date.present? ? helpers.format_date(date) : "—" - end - end - end -end diff --git a/modules/resource_management/app/components/resource_allocations/outside_dates_step/footer_component.rb b/modules/resource_management/app/components/resource_allocations/warning_step/footer_component.rb similarity index 96% rename from modules/resource_management/app/components/resource_allocations/outside_dates_step/footer_component.rb rename to modules/resource_management/app/components/resource_allocations/warning_step/footer_component.rb index bd4ed31cf92..44f5ff25770 100644 --- a/modules/resource_management/app/components/resource_allocations/outside_dates_step/footer_component.rb +++ b/modules/resource_management/app/components/resource_allocations/warning_step/footer_component.rb @@ -29,11 +29,16 @@ #++ module ResourceAllocations - module OutsideDatesStep + module WarningStep class FooterComponent < ApplicationComponent include OpTurbo::Streamable include OpPrimer::ComponentHelpers + def initialize(overbooked: false) + super + @overbooked = overbooked + end + def wrapper_key ResourceAllocations::NewDialogComponent::FOOTER_ID end diff --git a/modules/resource_management/app/components/resource_allocations/warning_step/form_component.html.erb b/modules/resource_management/app/components/resource_allocations/warning_step/form_component.html.erb new file mode 100644 index 00000000000..f41694e7636 --- /dev/null +++ b/modules/resource_management/app/components/resource_allocations/warning_step/form_component.html.erb @@ -0,0 +1,90 @@ +<%= + component_wrapper do + flex_layout(align_items: :center, text_align: :center, p: 3) do |body| + if schedule_violation? + body.with_row(mb: 2) do + render(Primer::Beta::Octicon.new(:calendar, size: :medium, color: :danger)) + end + + body.with_row(mb: 2) do + render(Primer::Beta::Heading.new(tag: :h2, font_size: 4)) { outside_dates_heading } + end + + body.with_row(mb: 1) do + render(Primer::Beta::Text.new(tag: :p, color: :muted, m: 0)) { outside_dates_description } + end + + body.with_row(mb: overbooked? ? 4 : 0) do + render(Primer::Beta::Text.new(tag: :p, color: :muted, m: 0)) { outside_dates_confirmation } + end + end + + if overbooked? + body.with_row(mb: 2) do + render(Primer::Beta::Octicon.new(:hourglass, size: :medium, color: :danger)) + end + + body.with_row(mb: 2) do + render(Primer::Beta::Heading.new(tag: :h2, font_size: 4)) { overbooking_heading } + end + + body.with_row(mb: 3) do + render(Primer::Beta::Text.new(tag: :p, color: :muted, m: 0)) { overbooking_description } + end + + @overbooked_ranges.each do |range| + body.with_row(w: :full, mb: 2) do + render(Primer::Box.new(border: true, border_radius: 2, p: 3, text_align: :left)) do + flex_layout do |box| + box.with_row(mb: 2) do + render(Primer::Beta::Text.new(font_weight: :bold)) { "#{date_range(range.start_date, range.end_date)}:" } + end + + range.items.each do |item| + box.with_row(mt: 1) do + flex_layout(align_items: :center) do |row| + row.with_column(flex: 1, mr: 2) do + render(Primer::Beta::Text.new(color: candidate?(item) ? :danger : :default)) do + work_package_label(item) + end + end + + row.with_column do + render(Primer::Beta::Text.new(font_weight: :bold, color: candidate?(item) ? :danger : :default)) do + format_hours(item.minutes) + end + end + + if candidate?(item) + row.with_column(ml: 1) do + render(Primer::Beta::Octicon.new(:alert, color: :danger)) + end + end + end + end + end + end + end + end + end + end + + body.with_row(w: :full) do + form_with( + url: project_resource_allocations_path(@project), + method: :post, + id: ResourceAllocations::NewDialogComponent::FORM_ID, + data: { turbo_stream: true } + ) do + safe_join( + [ + hidden_field_tag("allocation_kind", @allocation_kind), + (hidden_field_tag("filters", @filters) if @filters.present?), + *@form_values.map { |name, value| hidden_field_tag("resource_allocation[#{name}]", value) } + ].compact + ) + end + end + end + end +%> diff --git a/modules/resource_management/app/components/resource_allocations/warning_step/form_component.rb b/modules/resource_management/app/components/resource_allocations/warning_step/form_component.rb new file mode 100644 index 00000000000..f9ca8d8b1bb --- /dev/null +++ b/modules/resource_management/app/components/resource_allocations/warning_step/form_component.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module ResourceAllocations + module WarningStep + # Final confirmation step shown before an allocation is created. It hosts the + # "outside dates" and "overbooking" warnings; either or both may be present. + class FormComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def initialize(allocation:, project:, allocation_kind:, form_values:, overbooked_ranges: [], + daily_working_minutes: nil, filters: nil) + super + @allocation = allocation + @project = project + @allocation_kind = allocation_kind + @form_values = form_values + @overbooked_ranges = overbooked_ranges + @daily_working_minutes = daily_working_minutes + @filters = filters + end + + def wrapper_key + ResourceAllocations::NewDialogComponent::BODY_ID + end + + def schedule_violation? + @allocation.schedule_violation.present? + end + + def overbooked? + @overbooked_ranges.any? + end + + private + + def outside_dates_heading + t("resource_management.allocate_resource_dialog.outside_dates.title") + end + + def outside_dates_description + t( + "resource_management.allocate_resource_dialog.outside_dates.description", + resource_dates: date_range(@allocation.start_date, @allocation.end_date), + work_package_dates: date_range(@allocation.entity_start_date, @allocation.entity_due_date) + ) + end + + def outside_dates_confirmation + t("resource_management.allocate_resource_dialog.outside_dates.confirm_#{@allocation.schedule_violation}") + end + + def overbooking_heading + t("resource_management.allocate_resource_dialog.overbooking.title") + end + + def overbooking_description + t( + "resource_management.allocate_resource_dialog.overbooking.description", + user: @allocation.principal.name, + hours: format_hours(@daily_working_minutes) + ) + end + + # The work package for each forced item, restricted to those the current + # user may see. Items for unreadable work packages render anonymously. + def work_packages_by_id + @work_packages_by_id ||= + WorkPackage + .visible(User.current) + .where(id: @overbooked_ranges.flat_map(&:work_package_ids).uniq) + .index_by(&:id) + end + + def work_package_for(item) + work_packages_by_id[item.work_package_id] + end + + # The label for a forced item's row. Items whose work package the user may + # not see are shown anonymously to avoid leaking subjects across projects. + def work_package_label(item) + work_package = work_package_for(item) + return t("resource_management.allocate_resource_dialog.overbooking.other_allocation") if work_package.nil? + + "#{work_package.type.name} #{work_package.subject}" + end + + def candidate?(item) + item.id == ResourceAllocations::Availability::CANDIDATE_ID + end + + def date_range(from_date, to_date) + "#{format_or_dash(from_date)} - #{format_or_dash(to_date)}" + end + + def format_or_dash(date) + date.present? ? helpers.format_date(date) : "—" + end + + def format_hours(minutes) + hours = minutes.to_f / 60 + formatted = hours == hours.to_i ? hours.to_i : hours.round(2) + "#{formatted}h" + end + end + end +end diff --git a/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb b/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb index 690820e25c2..8976772b5eb 100644 --- a/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb +++ b/modules/resource_management/app/controllers/resource_management/resource_allocations_controller.rb @@ -57,7 +57,7 @@ module ::ResourceManagement validation = set_attributes(create_params) return render_allocation_step(validation.result, status: :unprocessable_entity) if validation.failure? - return render_outside_dates_step(validation.result) if needs_date_confirmation?(validation.result) + return render_warning_step(validation.result) if needs_confirmation?(validation.result) persist_allocation end @@ -81,17 +81,23 @@ module ::ResourceManagement respond_with_turbo_streams(status:) end - def render_outside_dates_step(allocation) + def render_warning_step(allocation) + ranges = overbooked_ranges(allocation) + replace_via_turbo_stream( - component: ResourceAllocations::OutsideDatesStep::FormComponent.new( + component: ResourceAllocations::WarningStep::FormComponent.new( allocation:, project: @project, allocation_kind:, form_values: submitted_allocation_params, - filters: params[:filters] + filters: params[:filters], + overbooked_ranges: ranges, + daily_working_minutes: daily_working_minutes(allocation, ranges) ) ) - replace_via_turbo_stream(component: ResourceAllocations::OutsideDatesStep::FooterComponent.new) + replace_via_turbo_stream( + component: ResourceAllocations::WarningStep::FooterComponent.new(overbooked: ranges.any?) + ) respond_with_turbo_streams end @@ -111,8 +117,48 @@ module ::ResourceManagement render_allocation_step(set_attributes(create_params).result) end - def needs_date_confirmation?(allocation) - allocation.schedule_violation && params[:confirmed].blank? + # A final confirmation step is shown when the allocation falls outside its + # work package's dates and/or would overbook the assigned user. Both + # warnings share the one step for now. + def needs_confirmation?(allocation) + return false if params[:confirmed].present? + + allocation.schedule_violation.present? || overbooked_ranges(allocation).any? + end + + def overbooked_ranges(allocation) + @overbooked_ranges ||= compute_overbooked_ranges(allocation) + end + + # Overbooking is a user-level concern, so it is only checked for allocations + # assigned to a specific user, and only when that user has working time + # configured (otherwise their capacity is unknown, not zero). + def compute_overbooked_ranges(allocation) + return [] unless overbooking_checkable?(allocation) + + availability(allocation).overbooking_with( + start_date: allocation.start_date, + end_date: allocation.end_date, + minutes: allocation.allocated_time, + work_package_id: allocation.entity_id + ) + end + + def overbooking_checkable?(allocation) + allocation.principal.present? && + allocation.start_date.present? && allocation.end_date.present? && + allocation.allocated_time.to_i.positive? && + UserWorkingHours.for_user(allocation.principal).exists? + end + + def daily_working_minutes(allocation, ranges) + return if ranges.empty? + + availability(allocation).max_daily_minutes(start_date: allocation.start_date, end_date: allocation.end_date) + end + + def availability(allocation) + @availability ||= ResourceAllocations::Availability.new(user: allocation.principal) end def set_attributes(attributes) diff --git a/modules/resource_management/app/models/resource_allocations/overbooked_range.rb b/modules/resource_management/app/models/resource_allocations/overbooked_range.rb index 71736c3d6f8..e15a44a753b 100644 --- a/modules/resource_management/app/models/resource_allocations/overbooked_range.rb +++ b/modules/resource_management/app/models/resource_allocations/overbooked_range.rb @@ -40,6 +40,9 @@ module ResourceAllocations attribute :end_date, :date attribute :work_package_ids, default: -> { [] } attribute :over_by_minutes, :integer + # The WorkItems forced into this range, carried so a warning can list each + # work package's hours and flag the one that was just allocated. + attribute :items, default: -> { [] } def covers?(date) (start_date..end_date).cover?(date) diff --git a/modules/resource_management/app/services/resource_allocations/availability.rb b/modules/resource_management/app/services/resource_allocations/availability.rb index 250adb6d7eb..18c71477bb4 100644 --- a/modules/resource_management/app/services/resource_allocations/availability.rb +++ b/modules/resource_management/app/services/resource_allocations/availability.rb @@ -36,6 +36,10 @@ module ResourceAllocations # projects (capacity is a user-level property). Filter-based allocations have no # principal and are excluded. class Availability + # Identifies the prospective allocation amongst the existing work items when + # checking a not-yet-persisted allocation. + CANDIDATE_ID = :candidate + def initialize(user:) @user = user end @@ -67,10 +71,29 @@ module ResourceAllocations # Whether a prospective allocation would still fit. `exclude_id` drops an # existing allocation from the check (e.g. the one being edited). def fits?(start_date:, end_date:, minutes:, exclude_id: nil) - candidate = WorkItem.new(id: :candidate, start_date:, end_date:, minutes:) + overbooking_with(start_date:, end_date:, minutes:, exclude_id:).empty? + end + + # The overbooked ranges that would result from adding a prospective + # allocation. The candidate is included in the ranges' items (carrying + # `work_package_id`) so a warning can list and flag it. Empty when it fits. + # `exclude_id` drops an existing allocation from the check (e.g. the one + # being edited). + def overbooking_with(start_date:, end_date:, minutes:, work_package_id: nil, exclude_id: nil) + candidate = WorkItem.new(id: CANDIDATE_ID, start_date:, end_date:, minutes:, work_package_id:) work_items = items.reject { |item| item.id == exclude_id } << candidate - OverbookingAnalysis.new(calendar: calendar_for(work_items), items: work_items).call.empty? + OverbookingAnalysis.new(calendar: calendar_for(work_items), items: work_items).call + end + + # The user's highest single-day working capacity (in minutes) across the + # range, i.e. the most they could work on any one of those days. + def max_daily_minutes(start_date:, end_date:) + WorkingTimeCalendar + .new(user: @user, range: start_date..end_date) + .each_day + .map { |_date, minutes| minutes } + .max || 0 end private diff --git a/modules/resource_management/app/services/resource_allocations/overbooking_analysis.rb b/modules/resource_management/app/services/resource_allocations/overbooking_analysis.rb index f5c967e0aaa..4b65671d585 100644 --- a/modules/resource_management/app/services/resource_allocations/overbooking_analysis.rb +++ b/modules/resource_management/app/services/resource_allocations/overbooking_analysis.rb @@ -100,11 +100,13 @@ module ResourceAllocations def overbooked_range(first, last, violations) in_block = violations.select { |violation| violation.start_date >= first && violation.end_date <= last } + items = in_block.flat_map(&:items).uniq(&:id) OverbookedRange.new( start_date: first, end_date: last, - work_package_ids: in_block.flat_map { |violation| violation.items.map(&:work_package_id) }.compact.uniq, + items:, + work_package_ids: items.filter_map(&:work_package_id).uniq, over_by_minutes: in_block.map(&:over_by_minutes).max ) end diff --git a/modules/resource_management/config/locales/en.yml b/modules/resource_management/config/locales/en.yml index eedf079eaff..c46c7a25823 100644 --- a/modules/resource_management/config/locales/en.yml +++ b/modules/resource_management/config/locales/en.yml @@ -82,6 +82,13 @@ en: description: >- The selected resource dates, %{resource_dates}, are outside of the work package's dates, %{work_package_dates}. title: Do you want to allocate resources outside the work package's dates? + overbooking: + description: >- + The selected user, %{user}, would be allocated beyond their maximum hours per day during this period. + This user works %{hours} per day. + other_allocation: Other allocation + submit: Allocate overtime + title: Do you want to allocate overtime to the user? submit: Allocate success_message: Resource allocated. title: Allocate resource diff --git a/modules/resource_management/spec/requests/resource_allocations_spec.rb b/modules/resource_management/spec/requests/resource_allocations_spec.rb index 753a3362102..4602aa51c21 100644 --- a/modules/resource_management/spec/requests/resource_allocations_spec.rb +++ b/modules/resource_management/spec/requests/resource_allocations_spec.rb @@ -313,6 +313,68 @@ RSpec.describe "ResourceAllocations requests", end.to change(ResourceAllocation, :count).by(1) end end + + context "when the allocation would overbook the assigned user" do + shared_let(:working_assignee) do + create(:user, member_with_permissions: { project => %i[view_work_packages] }).tap do |assignee| + # Mon-Fri 8h => 480 minutes/day of capacity. + create(:user_working_hours, user: assignee, valid_from: Date.new(2025, 1, 1)) + end + end + + # 40h (2400 min) across Mon-Tue (960 min of capacity) overbooks the user. + let(:base_params) do + { + allocation_kind: "principal", + resource_allocation: { + principal_id: working_assignee.id, + entity_type: "WorkPackage", + entity_id: work_package.id, + start_date: "2026-03-02", + end_date: "2026-03-03", + allocated_hours: "40h" + } + } + end + + it "does not create yet and renders the overbooking confirmation step" do + expect do + post project_resource_allocations_path(project), params: base_params, as: :turbo_stream + end.not_to change(ResourceAllocation, :count) + + expect(response).to have_http_status(:ok) + expect(response.body).to include(I18n.t("resource_management.allocate_resource_dialog.overbooking.title")) + expect(response.body).to include('name="confirmed"') + end + + it "creates the allocation once confirmed" do + expect do + post project_resource_allocations_path(project), + params: base_params.merge(confirmed: "1"), + as: :turbo_stream + end.to change(ResourceAllocation, :count).by(1) + end + end + + context "when the assigned user has no working time configured" do + it "skips the overbooking check and creates directly" do + expect do + post project_resource_allocations_path(project), + params: { + allocation_kind: "principal", + resource_allocation: { + principal_id: assignee.id, + entity_type: "WorkPackage", + entity_id: work_package.id, + start_date: "2026-03-02", + end_date: "2026-03-03", + allocated_hours: "40h" + } + }, + as: :turbo_stream + end.to change(ResourceAllocation, :count).by(1) + end + end end context "without the allocate_user_resources permission" do diff --git a/modules/resource_management/spec/services/resource_allocations/availability_spec.rb b/modules/resource_management/spec/services/resource_allocations/availability_spec.rb index c16cd678556..e01cd65ef9b 100644 --- a/modules/resource_management/spec/services/resource_allocations/availability_spec.rb +++ b/modules/resource_management/spec/services/resource_allocations/availability_spec.rb @@ -127,4 +127,48 @@ RSpec.describe ResourceAllocations::Availability do expect(availability.fits?(start_date: monday, end_date: friday, minutes: 2400, exclude_id: existing.id)).to be true end end + + describe "#overbooking_with" do + it "is empty when the prospective allocation still fits" do + allocate(600) + + ranges = availability.overbooking_with(start_date: monday, end_date: friday, minutes: 1000) + + expect(ranges).to be_empty + end + + it "returns the overbooked range including the candidate, flagged by its id" do + wp = create(:work_package) + allocate(1500, entity: wp) + candidate_wp = create(:work_package) + + range = availability + .overbooking_with(start_date: monday, end_date: friday, minutes: 1500, work_package_id: candidate_wp.id) + .sole + + expect(range.work_package_ids).to contain_exactly(wp.id, candidate_wp.id) + candidate = range.items.find { |item| item.id == described_class::CANDIDATE_ID } + expect(candidate.work_package_id).to eq(candidate_wp.id) + expect(candidate.minutes).to eq(1500) + end + + it "overbooks even without existing allocations when the candidate alone exceeds capacity" do + # 2400 minutes over Mon-Tue against an 8h/day (960 min) window. + ranges = availability.overbooking_with(start_date: monday, end_date: tuesday, minutes: 2400) + + expect(ranges).not_to be_empty + end + end + + describe "#max_daily_minutes" do + it "returns the highest single-day capacity in the range" do + expect(availability.max_daily_minutes(start_date: monday, end_date: friday)).to eq(480) + end + + it "is zero when the user has no working time configured" do + other = described_class.new(user: create(:user)) + + expect(other.max_daily_minutes(start_date: monday, end_date: friday)).to eq(0) + end + end end diff --git a/modules/resource_management/spec/services/resource_allocations/overbooking_analysis_spec.rb b/modules/resource_management/spec/services/resource_allocations/overbooking_analysis_spec.rb index be515aeec17..0d4d9db9581 100644 --- a/modules/resource_management/spec/services/resource_allocations/overbooking_analysis_spec.rb +++ b/modules/resource_management/spec/services/resource_allocations/overbooking_analysis_spec.rb @@ -65,6 +65,11 @@ RSpec.describe ResourceAllocations::OverbookingAnalysis do over_by_minutes: 1200 - 960 ) end + + it "carries the forced work items so a warning can list them" do + expect(ranges.first.items.map(&:id)).to eq([7]) + expect(ranges.first.items.map(&:minutes)).to eq([1200]) + end end context "with two allocations that each fit alone but collide over a shared interval" do