diff --git a/app/controllers/adjustments_controller.rb b/app/controllers/adjustments_controller.rb index e531e7af06..9e676be5a9 100644 --- a/app/controllers/adjustments_controller.rb +++ b/app/controllers/adjustments_controller.rb @@ -34,7 +34,7 @@ def new @adjustment = current_organization.adjustments.new @adjustment.line_items.build @storage_locations = current_organization.storage_locations.active - @items = current_organization.items.loose.active.alphabetized + @items = current_organization.concrete_items.active.alphabetized end # POST /adjustments @@ -55,7 +55,7 @@ def create def load_form_collections @storage_locations = current_organization.storage_locations.active - @items = current_organization.items.loose.alphabetized + @items = current_organization.concrete_items.alphabetized end def adjustment_params diff --git a/app/controllers/items_controller.rb b/app/controllers/items_controller.rb index 6dc902eb17..6be2f18aa6 100644 --- a/app/controllers/items_controller.rb +++ b/app/controllers/items_controller.rb @@ -4,14 +4,14 @@ class ItemsController < ApplicationController def index @items = current_organization .items - .includes(:kit, :line_items, :request_units, :item_category) + .includes(:line_items, :request_units, :item_category) .alphabetized .class_filter(filter_params) .group('items.id') @items = @items.active unless params[:include_inactive_items] @item_categories = current_organization.item_categories.includes(:items).order('name ASC') - @kits = current_organization.kits.includes(kit_item: {line_items: :item}) + @kits = current_organization.kits.includes(line_items: :item) @storages = current_organization.storage_locations.active.order(id: :asc) @include_inactive_items = params[:include_inactive_items] @@ -139,7 +139,7 @@ def remove_category def reporting_category_hint item = current_organization.items.find(params[:id]) - if item.kit_id + if item.is_a?(Kit) "Kits are reported based on their contents." end end diff --git a/app/controllers/kits_controller.rb b/app/controllers/kits_controller.rb index 0b9735f56f..ec23122ba5 100644 --- a/app/controllers/kits_controller.rb +++ b/app/controllers/kits_controller.rb @@ -4,7 +4,7 @@ def show end def index - @kits = current_organization.kits.includes(kit_item: {line_items: :item}).class_filter(filter_params) + @kits = current_organization.kits.includes(line_items: :item).class_filter(filter_params) @inventory = View::Inventory.new(current_organization.id) unless params[:include_inactive_items] @kits = @kits.active @@ -16,8 +16,7 @@ def new load_form_collections @kit = current_organization.kits.new - @kit.kit_item = KitItem.new(organization: current_organization) - @kit.kit_item.line_items.build + @kit.line_items.build end def create @@ -32,13 +31,9 @@ def create .map { |error| formatted_error_message(error) } .join(", ") - # Extract kit and item params separately since line_items belong to Item, not Kit - kit_only_params = kit_params.except(:line_items_attributes) - @kit = Kit.new(kit_only_params) load_form_collections - @kit.kit_item ||= KitItem.new(organization: current_organization, - **kit_params.slice(:line_items_attributes)) - @kit.kit_item.line_items.build if @kit.kit_item.line_items.empty? + @kit = current_organization.kits.new(kit_params) + @kit.line_items.build if @kit.line_items.empty? render :new end @@ -46,7 +41,7 @@ def create def deactivate @kit = current_organization.kits.find(params[:id]) - @kit.deactivate + @kit.deactivate! redirect_back_or_to(dashboard_path, notice: "Kit has been deactivated!") end @@ -92,14 +87,12 @@ def load_form_collections end def kit_params - kit_params = params.require(:kit).permit( + params.require(:kit).permit( :name, :visible_to_partners, - :value_in_dollars - ) - item_params = params.require(:kit_item) - .permit(line_items_attributes: [:item_id, :quantity, :_destroy]) - kit_params.to_h.merge(item_params.to_h) + :value_in_dollars, + line_items_attributes: [:item_id, :quantity, :_destroy] + ).to_h end def kit_adjustment_params diff --git a/app/events/kit_allocate_event.rb b/app/events/kit_allocate_event.rb index 46160a83a0..aa34b0c078 100644 --- a/app/events/kit_allocate_event.rb +++ b/app/events/kit_allocate_event.rb @@ -1,6 +1,6 @@ class KitAllocateEvent < Event def self.event_line_items(kit, storage_location, quantity) - items = kit.kit_item.line_items.map do |item| + items = kit.line_items.map do |item| EventTypes::EventLineItem.new( quantity: item.quantity * quantity, item_id: item.item_id, @@ -11,8 +11,8 @@ def self.event_line_items(kit, storage_location, quantity) end items.push(EventTypes::EventLineItem.new( quantity: quantity, - item_id: kit.kit_item.id, - item_value_in_cents: kit.kit_item.value_in_cents, + item_id: kit.id, + item_value_in_cents: kit.value_in_cents, to_storage_location: storage_location, from_storage_location: nil )) diff --git a/app/events/kit_deallocate_event.rb b/app/events/kit_deallocate_event.rb index 26a41a387d..88521212e1 100644 --- a/app/events/kit_deallocate_event.rb +++ b/app/events/kit_deallocate_event.rb @@ -1,6 +1,6 @@ class KitDeallocateEvent < Event def self.event_line_items(kit, storage_location, quantity) - items = kit.kit_item.line_items.map do |item| + items = kit.line_items.map do |item| EventTypes::EventLineItem.new( quantity: item.quantity * quantity, item_id: item.item_id, @@ -11,8 +11,8 @@ def self.event_line_items(kit, storage_location, quantity) end items.push(EventTypes::EventLineItem.new( quantity: quantity, - item_id: kit.kit_item.id, - item_value_in_cents: kit.kit_item.value_in_cents, + item_id: kit.id, + item_value_in_cents: kit.value_in_cents, from_storage_location: storage_location, to_storage_location: nil )) diff --git a/app/helpers/kits_helper.rb b/app/helpers/kits_helper.rb index df70b5abcf..19817092ce 100644 --- a/app/helpers/kits_helper.rb +++ b/app/helpers/kits_helper.rb @@ -2,7 +2,7 @@ module KitsHelper def deactivate_kit_button(kit, inventory) options = {class: "deactivate-kit-button"} span_options = {} - unless kit.can_deactivate?(inventory) + unless kit.can_deactivate_or_delete?(inventory) msg = "Can't deactivate while a storage location still has kits." options[:enabled] = false span_options = {title: msg, class: "tooltip-target"} diff --git a/app/models/concrete_item.rb b/app/models/concrete_item.rb index 7a56d8f80f..6ee4469e30 100644 --- a/app/models/concrete_item.rb +++ b/app/models/concrete_item.rb @@ -23,4 +23,5 @@ # organization_id :integer # class ConcreteItem < Item + validates :reporting_category, presence: true end diff --git a/app/models/item.rb b/app/models/item.rb index e9776300a6..ce938e88c9 100644 --- a/app/models/item.rb +++ b/app/models/item.rb @@ -31,12 +31,10 @@ class Item < ApplicationRecord include Itemizable after_initialize :set_default_distribution_quantity, if: :new_record? - after_update :update_associated_kit_name, if: -> { kit.present? } before_destroy :validate_destroy, prepend: true belongs_to :organization # If these are universal this isn't necessary belongs_to :base_item, counter_cache: :item_count, primary_key: :partner_key, foreign_key: :partner_key, inverse_of: :items, optional: true - belongs_to :kit, optional: true belongs_to :item_category, optional: true validates :additional_info, length: { maximum: 500 } @@ -46,7 +44,6 @@ class Item < ApplicationRecord validates :on_hand_recommended_quantity, numericality: { greater_than_or_equal_to: 0 }, allow_blank: true validates :on_hand_minimum_quantity, numericality: { greater_than_or_equal_to: 0 } validates :package_size, numericality: { greater_than_or_equal_to: 0 }, allow_blank: true - validates :reporting_category, presence: true, unless: proc { |i| i.kit } validate -> { line_items_quantity_is_at_least(1) } has_many :used_line_items, dependent: :destroy, class_name: "LineItem" @@ -57,10 +54,6 @@ class Item < ApplicationRecord has_many :request_units, class_name: "ItemUnit", dependent: :destroy scope :active, -> { where(active: true) } - - # :housing_a_kit are items which house a kit, NOT items is_in_kit - scope :housing_a_kit, -> { where.not(kit_id: nil) } - scope :loose, -> { where(kit_id: nil) } scope :inactive, -> { where.not(active: true) } scope :visible, -> { where(visible_to_partners: true) } @@ -106,17 +99,17 @@ def in_request? def is_in_kit?(kits = nil) if kits - kits.any? { |k| k.kit_item.line_items.map(&:item_id).include?(id) } + kits.any? { |k| k.line_items.map(&:item_id).include?(id) } else organization.kits .active - .joins(kit_item: :line_items) + .joins(:line_items) .where(line_items: { item_id: id}).any? end end def can_delete?(inventory = nil, kits = nil) - can_deactivate_or_delete?(inventory, kits) && used_line_items.none? && !barcode_count&.positive? && !in_request? && kit.blank? + can_deactivate_or_delete?(inventory, kits) && used_line_items.none? && !barcode_count&.positive? && !in_request? end # @return [Boolean] @@ -141,11 +134,7 @@ def deactivate! unless can_deactivate_or_delete? raise "Cannot deactivate item - it is in a storage location or kit!" end - if kit - kit.deactivate - else - update!(active: false) - end + update!(active: false) end # @return [String] @@ -199,10 +188,11 @@ def sync_request_units!(unit_ids) private def set_default_distribution_quantity - self.distribution_quantity ||= kit_id.present? ? 1 : 50 + self.distribution_quantity ||= default_distribution_quantity end - def update_associated_kit_name - kit.update(name: name) + # Overridden in Kit (kits default to 1). + def default_distribution_quantity + 50 end end diff --git a/app/models/kit.rb b/app/models/kit.rb index 2258f07fe8..c1539afc43 100644 --- a/app/models/kit.rb +++ b/app/models/kit.rb @@ -1,52 +1,51 @@ # == Schema Information # -# Table name: kits +# Table name: items # -# id :bigint not null, primary key -# active :boolean default(TRUE) -# name :string not null -# value_in_cents :integer default(0) -# visible_to_partners :boolean default(TRUE), not null -# created_at :datetime not null -# updated_at :datetime not null -# organization_id :integer not null +# id :integer not null, primary key +# active :boolean default(TRUE) +# additional_info :text +# barcode_count :integer +# distribution_quantity :integer +# name :string +# on_hand_minimum_quantity :integer default(0), not null +# on_hand_recommended_quantity :integer +# package_size :integer +# partner_key :string +# reporting_category :string +# type :string default("ConcreteItem"), not null +# value_in_cents :integer default(0) +# visible_to_partners :boolean default(TRUE), not null +# created_at :datetime not null +# updated_at :datetime not null +# item_category_id :integer +# kit_id :integer +# organization_id :integer # -class Kit < ApplicationRecord - has_paper_trail - include Filterable - include Valuable - - belongs_to :organization - has_one :kit_item, dependent: :restrict_with_exception - - scope :active, -> { where(active: true) } - scope :alphabetized, -> { order(:name) } +class Kit < Item scope :by_name, ->(name) { where("name ILIKE ?", "%#{name}%") } - validates :name, presence: true - validates :name, uniqueness: { scope: :organization } - - # @param inventory [View::Inventory] - # @return [Boolean] - def can_deactivate?(inventory = nil) - inventory ||= View::Inventory.new(organization_id) - inventory.quantity_for(item_id: kit_item.id).zero? - end - - def deactivate - update!(active: false) - kit_item.update!(active: false) + # Kits are managed through the kits UI (deactivate/reactivate), not the normal + # item-deletion path. + def can_delete?(inventory = nil, kits = nil) + false end # Kits can't reactivate if they have any inactive items, because now whenever they are allocated # or deallocated, we are changing inventory for inactive items (which we don't allow). # @return [Boolean] def can_reactivate? - kit_item.line_items.joins(:item).where(items: { active: false }).none? + line_items.joins(:item).where(items: {active: false}).none? end def reactivate update!(active: true) - kit_item.update!(active: true) + end + + private + + # Kits default to a distribution quantity of 1 (a single kit), not 50. + def default_distribution_quantity + 1 end end diff --git a/app/models/kit_item.rb b/app/models/kit_item.rb deleted file mode 100644 index 9081efd30e..0000000000 --- a/app/models/kit_item.rb +++ /dev/null @@ -1,29 +0,0 @@ -# == Schema Information -# -# Table name: items -# -# id :integer not null, primary key -# active :boolean default(TRUE) -# additional_info :text -# barcode_count :integer -# distribution_quantity :integer -# name :string -# on_hand_minimum_quantity :integer default(0), not null -# on_hand_recommended_quantity :integer -# package_size :integer -# partner_key :string -# reporting_category :string -# type :string default("ConcreteItem"), not null -# value_in_cents :integer default(0) -# visible_to_partners :boolean default(TRUE), not null -# created_at :datetime not null -# updated_at :datetime not null -# item_category_id :integer -# kit_id :integer -# organization_id :integer -# -class KitItem < Item - # for now. Technically not optional, but since we will be changing this to be standalone (no kit), - # there isn't really a reason to enforce this at the moment. - belongs_to :kit, optional: true -end diff --git a/app/models/organization.rb b/app/models/organization.rb index 464b0d117a..d6020c521e 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -85,6 +85,7 @@ class Organization < ApplicationRecord has_many :product_drive_tags, -> { by_type("ProductDrive") }, class_name: "Tag", inverse_of: false has_many :inventory_items, through: :storage_locations + has_many :concrete_items has_many :kits has_many :transfers has_many :users, -> { distinct }, through: :roles diff --git a/app/services/kit_create_service.rb b/app/services/kit_create_service.rb index 5be5670788..68fddab033 100644 --- a/app/services/kit_create_service.rb +++ b/app/services/kit_create_service.rb @@ -22,34 +22,24 @@ def call return self unless valid? organization.transaction do - # Create the Kit record - line_items = kit_params.delete(:line_items_attributes) - @kit = Kit.new(kit_params_with_organization) - @kit.save! - if line_items.blank? + if kit_params[:line_items_attributes].blank? + @kit = build_kit @kit.errors.add(:base, 'At least one item is required') raise ActiveRecord::RecordInvalid.new(@kit) end - # Find or create the BaseItem for all items housing kits - item_housing_a_kit_base_item = KitCreateService.find_or_create_kit_base_item! - - # Create the item + # A kit is an Item (STI type 'Kit') that contains other items as line items. item_creation = ItemCreateService.new( organization_id: organization.id, - item_params: { - type: 'KitItem', - line_items_attributes: line_items, - name: kit.name, - partner_key: item_housing_a_kit_base_item.partner_key, - kit_id: kit.id - } + item_params: kit_attributes ) item_creation_result = item_creation.call unless item_creation_result.success? raise item_creation_result.error end + + @kit = item_creation_result.value rescue StandardError => e errors.add(:base, e.message) raise ActiveRecord::Rollback @@ -61,14 +51,21 @@ def call private attr_reader :organization_id, :kit_params + def organization @organization ||= Organization.find_by(id: organization_id) end - def kit_params_with_organization - kit_params.merge({ - organization_id: organization.id - }) + # All kits share the same "Kit" base item / partner_key. + def kit_attributes + kit_params.merge( + type: 'Kit', + partner_key: KitCreateService.find_or_create_kit_base_item!.partner_key + ) + end + + def build_kit + organization.kits.new(kit_params.except(:line_items_attributes)) end def valid? @@ -87,9 +84,8 @@ def valid? def kit_validation_errors return @kit_validation_errors if @kit_validation_errors - # Exclude line_items_attributes as they belong to the Item, not the Kit - kit_only_params = kit_params_with_organization.except(:line_items_attributes) - kit = Kit.new(kit_only_params) + # Exclude line_items_attributes; the line item validity is checked when the item is saved. + kit = build_kit kit.valid? @kit_validation_errors = kit.errors diff --git a/app/services/reports/adult_incontinence_report_service.rb b/app/services/reports/adult_incontinence_report_service.rb index ef77136aae..66cb86a6d2 100644 --- a/app/services/reports/adult_incontinence_report_service.rb +++ b/app/services/reports/adult_incontinence_report_service.rb @@ -108,7 +108,7 @@ def distributed_adult_incontinence_items_from_kits WHERE distributions.organization_id = ? AND EXTRACT(year FROM issued_at) = ? AND kit_items.reporting_category = 'adult_incontinence' - AND items.kit_id IS NOT NULL + AND items.type = 'Kit' AND kit_line_items.itemizable_type = 'Item'; SQL @@ -128,35 +128,33 @@ def total_people_served_with_loose_supplies_per_month .distributions .for_year(year) .joins(line_items: :item) - .merge(Item.adult_incontinence.where(kit_id: nil)) # exclude kits + .merge(ConcreteItem.adult_incontinence) # exclude kits .sum('line_items.quantity / COALESCE(items.distribution_quantity, 50.0)') total_quantity.to_f / 12.0 end + # The distributed "kits" are Kits that appear directly in distribution line items. def distributed_kits_for_year organization .distributions .for_year(year) - .joins(line_items: { item: :kit }) + .joins(line_items: :item) + .where(items: { type: 'Kit' }) .distinct - .pluck('kits.id') + .pluck('items.id') end def total_distributed_kits_containing_adult_incontinence_items_per_month kits = Kit.where(id: distributed_kits_for_year).select do |kit| - kit.kit_item.items.adult_incontinence.exists? + kit.items.adult_incontinence.exists? end total_assisted_adults = kits.sum do |kit| - kit_item = Item.where(kit_id: kit.id).first - - next 0 unless kit_item - organization .distributions .for_year(year) .joins(line_items: :item) - .where(line_items: { item_id: kit_item.id }) + .where(line_items: { item_id: kit.id }) .sum('line_items.quantity / COALESCE(items.distribution_quantity, 1.0)') end total_assisted_adults.to_i / 12.0 diff --git a/app/services/reports/children_served_report_service.rb b/app/services/reports/children_served_report_service.rb index f3f818d5af..38dd18162f 100644 --- a/app/services/reports/children_served_report_service.rb +++ b/app/services/reports/children_served_report_service.rb @@ -39,22 +39,24 @@ def total_children_served_with_loose_disposables .distributions .for_year(year) .joins(line_items: :item) - .merge(Item.loose.disposable_diapers) + .merge(ConcreteItem.disposable_diapers) .pick(Arel.sql("CEILING(SUM(line_items.quantity::numeric / COALESCE(items.distribution_quantity, 50)))")) .to_i end - # These joins look circular but are needed due to polymorphic relationships. - # A distribution has many line_items and items, but kits also - # have the same relationships and we want to perform calculations on the - # items in the kits not the kit items themselves. + # A distribution line item can reference a Kit. The kit's contents are its own + # line items, so we join from the distributed Kit to its contents to find the kits that + # contain disposable diapers, then count children served based on the kit's distribution_quantity. def children_served_with_kits_containing_disposables kits_subquery = organization .distributions .for_year(year) - .joins(line_items: { item: { kit: { kit_item: { line_items: :item} } }}) - .where("items_line_items.reporting_category = 'disposable_diapers'") - .select("DISTINCT ON (distributions.id, line_items.id, kits.id) line_items.quantity, items.distribution_quantity") + .joins(line_items: :item) + .joins("INNER JOIN line_items kit_contents ON kit_contents.itemizable_type = 'Item' AND kit_contents.itemizable_id = items.id") + .joins("INNER JOIN items kit_content_items ON kit_content_items.id = kit_contents.item_id") + .where(items: { type: 'Kit' }) + .where("kit_content_items.reporting_category = 'disposable_diapers'") + .select("DISTINCT ON (distributions.id, line_items.id, items.id) line_items.quantity, items.distribution_quantity") .to_sql Distribution diff --git a/app/services/reports/diaper_report_service.rb b/app/services/reports/diaper_report_service.rb index 19a6f83434..bf735de9a7 100644 --- a/app/services/reports/diaper_report_service.rb +++ b/app/services/reports/diaper_report_service.rb @@ -65,7 +65,7 @@ def distributed_disposable_diapers_from_kits INNER JOIN items AS kit_items ON kit_items.id = kit_line_items.item_id WHERE distributions.organization_id = ? AND EXTRACT(year FROM issued_at) = ? - AND items.kit_id IS NOT NULL + AND items.type = 'Kit' AND kit_items.reporting_category = 'disposable_diapers' AND kit_line_items.itemizable_type = 'Item'; SQL diff --git a/app/services/reports/period_supply_report_service.rb b/app/services/reports/period_supply_report_service.rb index 4383558531..2642253f87 100644 --- a/app/services/reports/period_supply_report_service.rb +++ b/app/services/reports/period_supply_report_service.rb @@ -118,7 +118,7 @@ def kit_items_calculation(itemizable_type, string_itemizable_type) INNER JOIN items AS kit_items ON kit_items.id = kit_line_items.item_id WHERE #{itemizable_type}.organization_id = ? AND EXTRACT(year FROM issued_at) = ? - AND items.kit_id IS NOT NULL + AND items.type = 'Kit' AND kit_items.reporting_category IN ('pads', 'tampons', 'period_liners', 'period_underwear', 'period_other') AND kit_line_items.itemizable_type = 'Item'; SQL diff --git a/app/views/items/_form.html.erb b/app/views/items/_form.html.erb index 20bdea9f62..ee5cf0e8d6 100644 --- a/app/views/items/_form.html.erb +++ b/app/views/items/_form.html.erb @@ -19,7 +19,7 @@