mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
Show overbooking warning for a user
This commit is contained in:
-38
@@ -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
|
||||
%>
|
||||
-78
@@ -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
|
||||
+6
-1
@@ -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
|
||||
+90
@@ -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
|
||||
%>
|
||||
+136
@@ -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
|
||||
+53
-7
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user