From b24566a895c9a7006cec3ee243440d64d83b8d64 Mon Sep 17 00:00:00 2001 From: Daniel Orner Date: Fri, 12 Jun 2026 16:32:58 -0400 Subject: [PATCH 1/4] Replace Kits with KitItems --- app/controllers/adjustments_controller.rb | 4 +- app/controllers/items_controller.rb | 6 +- app/controllers/kits_controller.rb | 35 ++-- app/events/kit_allocate_event.rb | 18 +- app/events/kit_deallocate_event.rb | 18 +- app/helpers/kits_helper.rb | 2 +- app/models/concrete_item.rb | 1 + app/models/event.rb | 2 +- app/models/item.rb | 28 +--- app/models/kit.rb | 52 ------ app/models/kit_item.rb | 28 +++- app/models/organization.rb | 2 +- app/services/kit_create_service.rb | 46 +++-- .../adult_incontinence_report_service.rb | 20 +-- .../reports/children_served_report_service.rb | 18 +- app/services/reports/diaper_report_service.rb | 2 +- .../reports/period_supply_report_service.rb | 2 +- app/views/items/_form.html.erb | 2 +- app/views/kits/_form.html.erb | 26 ++- app/views/kits/_table.html.erb | 4 +- app/views/kits/allocations.html.erb | 4 +- ...ributes_to_kit_items_and_repoint_events.rb | 36 ++++ db/schema.rb | 2 +- db/seeds.rb | 8 +- spec/events/inventory_aggregate_spec.rb | 12 +- spec/factories/items.rb | 3 +- spec/factories/kit_items.rb | 17 +- spec/factories/kits.rb | 30 ---- spec/models/item_spec.rb | 62 ++----- spec/models/kit_item_spec.rb | 157 ++++++++++++++++++ spec/models/kit_spec.rb | 131 --------------- spec/requests/items_requests_spec.rb | 6 +- spec/requests/kit_requests_spec.rb | 12 +- spec/services/kit_create_service_spec.rb | 32 ++-- .../adult_incontinence_report_service_spec.rb | 29 ++-- .../children_served_report_service_spec.rb | 37 ++--- .../reports/diaper_report_service_spec.rb | 4 +- .../period_supply_report_service_spec.rb | 24 +-- spec/system/kit_system_spec.rb | 20 +-- 39 files changed, 433 insertions(+), 509 deletions(-) delete mode 100644 app/models/kit.rb create mode 100644 db/migrate/20260612120000_copy_kit_attributes_to_kit_items_and_repoint_events.rb delete mode 100644 spec/factories/kits.rb create mode 100644 spec/models/kit_item_spec.rb delete mode 100644 spec/models/kit_spec.rb diff --git a/app/controllers/adjustments_controller.rb b/app/controllers/adjustments_controller.rb index e531e7af06..9c9513d914 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 = ConcreteItem.where(organization: current_organization).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 = ConcreteItem.where(organization: current_organization).alphabetized end def adjustment_params diff --git a/app/controllers/items_controller.rb b/app/controllers/items_controller.rb index 6dc902eb17..b6918a8825 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.kit_items.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?(KitItem) "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..1e30348ec0 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.kit_items.includes(line_items: :item).class_filter(filter_params) @inventory = View::Inventory.new(current_organization.id) unless params[:include_inactive_items] @kits = @kits.active @@ -15,9 +15,8 @@ def index 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 = current_organization.kit_items.new + @kit.line_items.build end def create @@ -32,26 +31,22 @@ 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.kit_items.new(kit_params) + @kit.line_items.build if @kit.line_items.empty? render :new end end def deactivate - @kit = current_organization.kits.find(params[:id]) - @kit.deactivate + @kit = current_organization.kit_items.find(params[:id]) + @kit.deactivate! redirect_back_or_to(dashboard_path, notice: "Kit has been deactivated!") end def reactivate - @kit = current_organization.kits.find(params[:id]) + @kit = current_organization.kit_items.find(params[:id]) if @kit.can_reactivate? @kit.reactivate redirect_back_or_to(dashboard_path, notice: "Kit has been reactivated!") @@ -61,7 +56,7 @@ def reactivate end def allocations - @kit = current_organization.kits.find(params[:id]) + @kit = current_organization.kit_items.find(params[:id]) @storage_locations = current_organization.storage_locations.active @inventory = View::Inventory.new(current_organization.id) @@ -69,7 +64,7 @@ def allocations end def allocate - @kit = current_organization.kits.find(params[:id]) + @kit = current_organization.kit_items.find(params[:id]) @storage_location = current_organization.storage_locations.active.find(kit_adjustment_params[:storage_location_id]) @change_by = kit_adjustment_params[:change_by].to_i begin @@ -92,14 +87,12 @@ def load_form_collections end def kit_params - kit_params = params.require(:kit).permit( + params.require(:kit_item).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..cd721ab8f1 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| + def self.event_line_items(kit_item, storage_location, quantity) + items = kit_item.line_items.map do |item| EventTypes::EventLineItem.new( quantity: item.quantity * quantity, item_id: item.item_id, @@ -11,22 +11,22 @@ 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_item.id, + item_value_in_cents: kit_item.value_in_cents, to_storage_location: storage_location, from_storage_location: nil )) items end - def self.publish(kit, storage_location, quantity) + def self.publish(kit_item, storage_location, quantity) create!( - eventable: kit, - group_id: "kit-allocate-#{kit.id}-#{SecureRandom.hex}", - organization_id: kit.organization_id, + eventable: kit_item, + group_id: "kit-allocate-#{kit_item.id}-#{SecureRandom.hex}", + organization_id: kit_item.organization_id, event_time: Time.zone.now, data: EventTypes::InventoryPayload.new( - items: event_line_items(kit, storage_location, quantity) + items: event_line_items(kit_item, storage_location, quantity) ) ) end diff --git a/app/events/kit_deallocate_event.rb b/app/events/kit_deallocate_event.rb index 26a41a387d..e9b611a392 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| + def self.event_line_items(kit_item, storage_location, quantity) + items = kit_item.line_items.map do |item| EventTypes::EventLineItem.new( quantity: item.quantity * quantity, item_id: item.item_id, @@ -11,22 +11,22 @@ 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_item.id, + item_value_in_cents: kit_item.value_in_cents, from_storage_location: storage_location, to_storage_location: nil )) items end - def self.publish(kit, storage_location, quantity) + def self.publish(kit_item, storage_location, quantity) create( - eventable: kit, - group_id: "kit-deallocate-#{kit.id}-#{SecureRandom.hex}", - organization_id: kit.organization_id, + eventable: kit_item, + group_id: "kit-deallocate-#{kit_item.id}-#{SecureRandom.hex}", + organization_id: kit_item.organization_id, event_time: Time.zone.now, data: EventTypes::InventoryPayload.new( - items: event_line_items(kit, storage_location, quantity) + items: event_line_items(kit_item, storage_location, quantity) ) ) end 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/event.rb b/app/models/event.rb index 44d9972948..f4aa1f730a 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -45,7 +45,7 @@ class Event < ApplicationRecord def no_intervening_snapshot return if is_a?(SnapshotEvent) return unless eventable.respond_to?(:organization) - return if eventable.is_a?(Kit) + return if eventable.is_a?(KitItem) intervening = SnapshotEvent.intervening(eventable) if intervening.present? diff --git a/app/models/item.rb b/app/models/item.rb index e9776300a6..2f8e03c788 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 + organization.kit_items .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 KitItem (kits default to 1). + def default_distribution_quantity + 50 end end diff --git a/app/models/kit.rb b/app/models/kit.rb deleted file mode 100644 index 2258f07fe8..0000000000 --- a/app/models/kit.rb +++ /dev/null @@ -1,52 +0,0 @@ -# == Schema Information -# -# Table name: kits -# -# 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 -# -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) } - 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) - 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? - end - - def reactivate - update!(active: true) - kit_item.update!(active: true) - end -end diff --git a/app/models/kit_item.rb b/app/models/kit_item.rb index 9081efd30e..932b0fb25b 100644 --- a/app/models/kit_item.rb +++ b/app/models/kit_item.rb @@ -23,7 +23,29 @@ # 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 + scope :by_name, ->(name) { where("name ILIKE ?", "%#{name}%") } + + # Kit items 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? + line_items.joins(:item).where(items: {active: false}).none? + end + + def reactivate + 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/organization.rb b/app/models/organization.rb index 464b0d117a..743768f947 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -85,7 +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 :kits + has_many :kit_items has_many :transfers has_many :users, -> { distinct }, through: :roles has_many :vendors diff --git a/app/services/kit_create_service.rb b/app/services/kit_create_service.rb index 5be5670788..07ff54964c 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_item @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 just a KitItem - an Item 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_item_params ) 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 kit items share the same "Kit" base item / partner_key. + def kit_item_params + kit_params.merge( + type: 'KitItem', + partner_key: KitCreateService.find_or_create_kit_base_item!.partner_key + ) + end + + def build_kit_item + organization.kit_items.new(kit_params.except(:line_items_attributes)) end def valid? @@ -87,11 +84,10 @@ 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) - kit.valid? + # Exclude line_items_attributes; the line item validity is checked when the item is saved. + kit_item = build_kit_item + kit_item.valid? - @kit_validation_errors = kit.errors + @kit_validation_errors = kit_item.errors end end diff --git a/app/services/reports/adult_incontinence_report_service.rb b/app/services/reports/adult_incontinence_report_service.rb index ef77136aae..53a3d237c0 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 = 'KitItem' AND kit_line_items.itemizable_type = 'Item'; SQL @@ -128,30 +128,28 @@ 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 KitItems 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: 'KitItem' }) .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 = KitItem.where(id: distributed_kits_for_year).select do |kit_item| + kit_item.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 - + total_assisted_adults = kit_items.sum do |kit_item| organization .distributions .for_year(year) diff --git a/app/services/reports/children_served_report_service.rb b/app/services/reports/children_served_report_service.rb index f3f818d5af..c1f741a4b1 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 (a KitItem). The kit's contents are its own + # line items, so we join from the distributed KitItem 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: 'KitItem' }) + .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..36c3acdad6 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 = 'KitItem' 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..3cae9b41b1 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 = 'KitItem' 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..040fc68dad 100644 --- a/app/views/items/_form.html.erb +++ b/app/views/items/_form.html.erb @@ -19,7 +19,7 @@
- <%= f.input :reporting_category, label: 'NDBN Reporting Category', collection: Item.reporting_categories_for_select, disabled: !!@item.kit_id, required: !@item.kit_id, hint: @reporting_category_hint %> + <%= f.input :reporting_category, label: 'NDBN Reporting Category', collection: Item.reporting_categories_for_select, disabled: @item.is_a?(KitItem), required: !@item.is_a?(KitItem), hint: @reporting_category_hint %>
<%= f.input :name, label: "Value per item", wrapper: :input_group do %> diff --git a/app/views/kits/_form.html.erb b/app/views/kits/_form.html.erb index f4da7936f7..ff9b77180e 100644 --- a/app/views/kits/_form.html.erb +++ b/app/views/kits/_form.html.erb @@ -1,4 +1,4 @@ -<%= simple_form_for @kit, remote: request.xhr?, data: { controller: "form-input" }, html: { class: 'form-horizontal' } do |f| %> +<%= simple_form_for @kit, url: kits_path, as: :kit_item, remote: request.xhr?, data: { controller: "form-input" }, html: { class: 'form-horizontal' } do |f| %>
@@ -19,19 +19,17 @@ <%= f.input_field :value_in_dollars, class: "form-control", min: 0 %> <% end %> - <%= fields_for @kit.kit_item do |ff| %> -
- Items in this Kit -
- <%= render 'line_items/line_item_fields', form: ff %> -
- -
- <% end %> +
+ Items in this Kit +
+ <%= render 'line_items/line_item_fields', form: f %> +
+ +
- <%= f.input :reporting_category, label: 'NDBN Reporting Category', collection: Item.reporting_categories_for_select, disabled: @item.is_a?(KitItem), required: !@item.is_a?(KitItem), hint: @reporting_category_hint %> + <%= f.input :reporting_category, label: 'NDBN Reporting Category', collection: Item.reporting_categories_for_select, disabled: @item.is_a?(Kit), required: !@item.is_a?(Kit), hint: @reporting_category_hint %>
<%= f.input :name, label: "Value per item", wrapper: :input_group do %> diff --git a/app/views/kits/_form.html.erb b/app/views/kits/_form.html.erb index ff9b77180e..dc250d5873 100644 --- a/app/views/kits/_form.html.erb +++ b/app/views/kits/_form.html.erb @@ -1,4 +1,4 @@ -<%= simple_form_for @kit, url: kits_path, as: :kit_item, remote: request.xhr?, data: { controller: "form-input" }, html: { class: 'form-horizontal' } do |f| %> +<%= simple_form_for @kit, remote: request.xhr?, data: { controller: "form-input" }, html: { class: 'form-horizontal' } do |f| %>
diff --git a/db/migrate/20260612130000_rename_kit_item_type_to_kit.rb b/db/migrate/20260612130000_rename_kit_item_type_to_kit.rb new file mode 100644 index 0000000000..0453ddee0c --- /dev/null +++ b/db/migrate/20260612130000_rename_kit_item_type_to_kit.rb @@ -0,0 +1,15 @@ +class RenameKitItemTypeToKit < ActiveRecord::Migration[8.0] + # The KitItem STI class was renamed to Kit. Update the `type` discriminator on existing + # item rows so they instantiate as the renamed class. Schema is unchanged. + def up + safety_assured do + execute("UPDATE items SET type = 'Kit', updated_at = NOW() WHERE type = 'KitItem'") + end + end + + def down + safety_assured do + execute("UPDATE items SET type = 'KitItem', updated_at = NOW() WHERE type = 'Kit'") + end + end +end diff --git a/db/schema.rb b/db/schema.rb index daa5b35d10..7329fb2cdf 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_06_12_120000) do +ActiveRecord::Schema[8.0].define(version: 2026_06_12_130000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" diff --git a/db/seeds.rb b/db/seeds.rb index ef6da3f81e..be325f1d2c 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -854,7 +854,7 @@ def seed_quantity(item_name, organization, storage_location, quantity) # Create kit inventory for storage locations complete_orgs.each do |org| org.storage_locations.active.each do |storage_location| - org.kit_items.active.each do |kit| + org.kits.active.each do |kit| # Create inventory for each kit InventoryItem.create!( storage_location: storage_location, @@ -951,15 +951,15 @@ def seed_quantity(item_name, organization, storage_location, quantity) end # Create some distributions that use kits instead of individual items - kit_items = org.kit_items.active - if kit_items.any? + kits = org.kits.active + if kits.any? 5.times do |index| issued_at = dates_generator.next storage_location = org.storage_locations.active.sample - kit_item = kit_items.sample + kit = kits.sample # Check if there's inventory for this kit - kit_inventory_qty = storage_location.item_total(kit_item.id) + kit_inventory_qty = storage_location.item_total(kit.id) next if kit_inventory_qty.zero? delivery_method = Distribution.delivery_methods.keys.sample @@ -981,7 +981,7 @@ def seed_quantity(item_name, organization, storage_location, quantity) kit_distribution.line_items.push( LineItem.new( quantity: distribution_qty, - item_id: kit_item.id + item_id: kit.id ) ) diff --git a/spec/factories/kit_items.rb b/spec/factories/kits.rb similarity index 97% rename from spec/factories/kit_items.rb rename to spec/factories/kits.rb index 0250c73e96..5cf60abdb1 100644 --- a/spec/factories/kit_items.rb +++ b/spec/factories/kits.rb @@ -23,7 +23,7 @@ # organization_id :integer # FactoryBot.define do - factory :kit_item, aliases: [:kit] do + factory :kit do sequence(:name) { |n| "#{n} - Dont test this" } partner_key { "kit" } reporting_category { nil } diff --git a/spec/models/item_spec.rb b/spec/models/item_spec.rb index e0adaf1734..a6471def4b 100644 --- a/spec/models/item_spec.rb +++ b/spec/models/item_spec.rb @@ -53,15 +53,15 @@ expect(subject.class).to respond_to :class_filter end - specify "KitItem returns all items which house a kit" do + specify "Kit returns all items which house a kit" do name = "test kit" kit_params = attributes_for(:kit, name: name) kit_params[:line_items_attributes] = [{item_id: create(:item).id, quantity: 1}] # shouldn't be counted KitCreateService.new(organization_id: organization.id, kit_params: kit_params).call create(:item) # shouldn't be counted - expect(KitItem.count).to eq(1) - expect(KitItem.first.name).to eq(name) + expect(Kit.count).to eq(1) + expect(Kit.first.name).to eq(name) end specify "ConcreteItem returns all items which do not house a kit" do @@ -311,10 +311,10 @@ end it 'deactivates a kit item' do - kit_item = create(:kit_item) - expect(kit_item).to be_active - kit_item.deactivate! - expect(kit_item.reload).not_to be_active + kit = create(:kit) + expect(kit).to be_active + kit.deactivate! + expect(kit.reload).not_to be_active end end @@ -418,7 +418,7 @@ end it "returns empty string when no reporting_category exists" do - item = KitItem.new(organization: organization) + item = Kit.new(organization: organization) expect(item.reporting_category).to eq(nil) expect(item.reporting_category_humanized).to eq("") @@ -432,7 +432,7 @@ end it "should set distribution_quantity to 1 for kits" do - item = KitItem.new + item = Kit.new expect(item.distribution_quantity).to eq(1) end end @@ -452,16 +452,16 @@ context "with kit and regular items" do let(:organization) { create(:organization) } let(:base_item) { create(:base_item, name: "Kit") } - let(:kit_item) { create(:kit_item, organization: organization, base_item: base_item) } + let(:kit) { create(:kit, organization: organization, base_item: base_item) } let(:regular_item) { create(:item, organization: organization) } it "has no reporting category" do - expect(kit_item.reporting_category).to be(nil) + expect(kit.reporting_category).to be(nil) end describe "#can_delete?" do it "returns false for kit items" do - expect(kit_item.can_delete?).to be false + expect(kit.can_delete?).to be false end it "returns true for regular items" do @@ -471,8 +471,8 @@ describe "#deactivate!" do it "deactivates the kit item" do - kit_item.deactivate! - expect(kit_item.reload.active).to be false + kit.deactivate! + expect(kit.reload.active).to be false end it "only deactivates regular items" do @@ -483,8 +483,8 @@ describe "#validate_destroy" do it "prevents deletion of kit items" do - expect { kit_item.destroy! }.to raise_error(ActiveRecord::RecordNotDestroyed) - expect(kit_item.errors[:base]).to include("Cannot delete item - it has already been used!") + expect { kit.destroy! }.to raise_error(ActiveRecord::RecordNotDestroyed) + expect(kit.errors[:base]).to include("Cannot delete item - it has already been used!") end it "allows deletion of regular items" do diff --git a/spec/models/kit_item_spec.rb b/spec/models/kit_spec.rb similarity index 83% rename from spec/models/kit_item_spec.rb rename to spec/models/kit_spec.rb index d864b90578..ea73567ad9 100644 --- a/spec/models/kit_item_spec.rb +++ b/spec/models/kit_spec.rb @@ -23,13 +23,13 @@ # organization_id :integer # -RSpec.describe KitItem, type: :model do +RSpec.describe Kit, type: :model do let(:organization) { create(:organization) } - let(:kit) { build(:kit_item, name: "Test Kit", organization: organization) } + let(:kit) { build(:kit, name: "Test Kit", organization: organization) } context "Validations >" do - subject { build(:kit_item, organization: organization) } + subject { build(:kit, organization: organization) } it { should validate_presence_of(:name) } it { should belong_to(:organization) } @@ -38,7 +38,7 @@ it "requires a unique name" do subject.save expect( - build(:kit_item, name: subject.name, organization: organization) + build(:kit, name: subject.name, organization: organization) ).not_to be_valid end @@ -67,20 +67,20 @@ a_name = "KitA" b_name = "KitB" c_name = "KitC" - create(:kit_item, name: c_name, organization: organization) - create(:kit_item, name: b_name, organization: organization) - create(:kit_item, name: a_name, organization: organization) + create(:kit, name: c_name, organization: organization) + create(:kit, name: b_name, organization: organization) + create(:kit, name: a_name, organization: organization) alphabetized_list = [a_name, b_name, c_name] - expect(organization.kit_items.alphabetized.count).to eq(3) - expect(organization.kit_items.alphabetized.map(&:name)).to eq(alphabetized_list) + expect(organization.kits.alphabetized.count).to eq(3) + expect(organization.kits.alphabetized.map(&:name)).to eq(alphabetized_list) end it "->by_name filters by name" do - create(:kit_item, name: "Newborn Kit", organization: organization) - create(:kit_item, name: "Toddler Kit", organization: organization) + create(:kit, name: "Newborn Kit", organization: organization) + create(:kit, name: "Toddler Kit", organization: organization) - expect(organization.kit_items.by_name("newborn").map(&:name)).to eq(["Newborn Kit"]) + expect(organization.kits.by_name("newborn").map(&:name)).to eq(["Newborn Kit"]) end end diff --git a/spec/requests/items_requests_spec.rb b/spec/requests/items_requests_spec.rb index 2c711782ba..ddd1a1574c 100644 --- a/spec/requests/items_requests_spec.rb +++ b/spec/requests/items_requests_spec.rb @@ -122,7 +122,7 @@ end context "when item is housing a kit" do - let(:item) { create(:kit_item, organization: organization) } + let(:item) { create(:kit, organization: organization) } it "shows the NDBN reporting category field disabled" do get edit_item_path(item) @@ -345,7 +345,7 @@ let!(:item) { create(:item, organization: organization, name: "ACTIVEITEM", reporting_category: :adult_incontinence, item_category_id: item_category.id, distribution_quantity: 2000, on_hand_recommended_quantity: 2348, package_size: 100, value_in_cents: 20000, on_hand_minimum_quantity: 1200, visible_to_partners: true) } let!(:item_unit_1) { create(:item_unit, item: item, name: 'ITEM1') } let!(:item_unit_2) { create(:item_unit, item: item, name: 'ITEM2') } - let!(:item_containing_kit) { create(:kit_item, organization: organization) } + let!(:item_containing_kit) { create(:kit, organization: organization) } it 'shows complete item details except custom request' do get item_path(id: item.id) diff --git a/spec/services/kit_create_service_spec.rb b/spec/services/kit_create_service_spec.rb index bdce61546b..02b75c6c5a 100644 --- a/spec/services/kit_create_service_spec.rb +++ b/spec/services/kit_create_service_spec.rb @@ -30,15 +30,15 @@ end context 'when the parameters are valid' do - it 'should create a new KitItem with line items' do - expect { subject }.to change { KitItem.all.count }.by(1) - kit = KitItem.last + it 'should create a new Kit with line items' do + expect { subject }.to change { Kit.all.count }.by(1) + kit = Kit.last expect(kit.line_items.count).to eq(3) end - it 'should return the created KitItem' do - expect(subject.kit).to eq(KitItem.last) - expect(subject.kit).to be_a(KitItem) + it 'should return the created Kit' do + expect(subject.kit).to eq(Kit.last) + expect(subject.kit).to be_a(Kit) end it 'should not create any other items' do @@ -51,8 +51,8 @@ allow_any_instance_of(ItemCreateService).to receive(:call).and_raise(raised_error) end - it 'should not create a KitItem' do - expect { subject }.not_to change { KitItem.all.count } + it 'should not create a Kit' do + expect { subject }.not_to change { Kit.all.count } end it 'should have an error that includes the raised error' do @@ -68,8 +68,8 @@ allow_any_instance_of(ItemCreateService).to receive(:call).and_return(failing_result) end - it 'should not create a KitItem' do - expect { subject }.not_to change { KitItem.all.count } + it 'should not create a Kit' do + expect { subject }.not_to change { Kit.all.count } end it 'should have an error that includes the error' do @@ -90,7 +90,7 @@ context 'because the kit_params is invalid for kit creation' do let(:kit_params) { { organization_id: organization_id } } let(:kit_validation_errors) do - kit = organization.kit_items.new + kit = organization.kits.new kit.valid? kit.errors end diff --git a/spec/services/reports/adult_incontinence_report_service_spec.rb b/spec/services/reports/adult_incontinence_report_service_spec.rb index 55e64237ac..527536f31b 100644 --- a/spec/services/reports/adult_incontinence_report_service_spec.rb +++ b/spec/services/reports/adult_incontinence_report_service_spec.rb @@ -42,27 +42,27 @@ let!(:ai_concrete_3) { create(:item, name: "Adult Briefs (Small)", reporting_category: "adult_incontinence", organization: organization) } let!(:non_ai_concrete) { create(:item, name: "Baby Wipes", reporting_category: "other", organization: organization) } - # Create kits (KitItems) and configure their contents with concrete items + # Create kits and configure their contents with concrete items let!(:kit_1) do - kit = create(:kit_item, organization: organization, distribution_quantity: 1) + kit = create(:kit, organization: organization, distribution_quantity: 1) kit.line_items = [create(:line_item, item: ai_concrete_1, quantity: 5)] kit end let!(:kit_2) do - kit = create(:kit_item, organization: organization, distribution_quantity: 1) + kit = create(:kit, organization: organization, distribution_quantity: 1) kit.line_items = [create(:line_item, item: ai_concrete_2, quantity: 5)] kit end let!(:kit_4) do - kit = create(:kit_item, organization: organization, distribution_quantity: 1) + kit = create(:kit, organization: organization, distribution_quantity: 1) kit.line_items = [create(:line_item, item: ai_concrete_3, quantity: 5)] kit end let!(:kit_3) do - kit = create(:kit_item, organization: organization) + kit = create(:kit, organization: organization) kit.line_items = [create(:line_item, item: non_ai_concrete, quantity: 5)] kit end diff --git a/spec/services/reports/children_served_report_service_spec.rb b/spec/services/reports/children_served_report_service_spec.rb index 604acc48b0..a7838674d1 100644 --- a/spec/services/reports/children_served_report_service_spec.rb +++ b/spec/services/reports/children_served_report_service_spec.rb @@ -29,13 +29,13 @@ toddler_disposable_kit_item = create(:item, name: "Toddler Disposable Diapers", reporting_category: :disposable_diapers) infant_disposable_kit_item = create(:item, name: "Infant Disposable Diapers", reporting_category: :disposable_diapers) - kit_1 = create(:kit_item, name: "Kit 1", organization: organization, distribution_quantity: 1) + kit_1 = create(:kit, name: "Kit 1", organization: organization, distribution_quantity: 1) kit_1.line_items = [ create(:line_item, item: toddler_disposable_kit_item), create(:line_item, item: infant_disposable_kit_item) ] - kit_2 = create(:kit_item, name: "Kit 2", organization: organization, distribution_quantity: 1) + kit_2 = create(:kit, name: "Kit 2", organization: organization, distribution_quantity: 1) kit_2.line_items = [ create(:line_item, item: toddler_disposable_kit_item), create(:line_item, item: infant_disposable_kit_item) @@ -82,7 +82,7 @@ toddler_disposable_kit_item = create(:item, name: "Toddler Disposable Diapers", reporting_category: :disposable_diapers) infant_disposable_kit_item = create(:item, name: "Infant Disposable Diapers", reporting_category: :disposable_diapers) - kit = create(:kit_item, name: "Kit 1", organization: organization, distribution_quantity: nil) + kit = create(:kit, name: "Kit 1", organization: organization, distribution_quantity: nil) kit.line_items = [ create(:line_item, item: toddler_disposable_kit_item), create(:line_item, item: infant_disposable_kit_item) @@ -122,7 +122,7 @@ not_disposable_kit_item = create(:item, name: "Adult Diapers", reporting_category: :adult_incontinence) # this quantity shouldn't matter so I'm setting it to a high number to ensure it isn't used - kit = create(:kit_item, name: "Kit 1", organization: organization, distribution_quantity: 3) + kit = create(:kit, name: "Kit 1", organization: organization, distribution_quantity: 3) kit.line_items = [ create(:line_item, quantity: 1000, item: toddler_disposable_kit_item), create(:line_item, quantity: 1000, item: infant_disposable_kit_item), diff --git a/spec/system/kit_system_spec.rb b/spec/system/kit_system_spec.rb index 8313bce6c2..ed0f486a34 100644 --- a/spec/system/kit_system_spec.rb +++ b/spec/system/kit_system_spec.rb @@ -38,12 +38,12 @@ kit_traits = attributes_for(:kit) fill_in "Name", with: kit_traits[:name] - find(:css, '#kit_item_value_in_dollars').set('10.10') + find(:css, '#kit_value_in_dollars').set('10.10') item = Item.last quantity_per_kit = 5 - select item.name, from: "kit_item_line_items_attributes_0_item_id" - find(:css, '#kit_item_line_items_attributes_0_quantity').set(quantity_per_kit) + select item.name, from: "kit_line_items_attributes_0_item_id" + find(:css, '#kit_line_items_attributes_0_quantity').set(quantity_per_kit) click_button "Save" @@ -73,7 +73,7 @@ expect(page).to have_content("Barcode Added to Inventory") # Check that item details have been filled in via javascript - expect(page).to have_field("kit_item_line_items_attributes_0_quantity", with: quantity) + expect(page).to have_field("kit_line_items_attributes_0_quantity", with: quantity) # Check that new field has been added via javascript expect(page).to have_css(".line_item_section", count: 2) end @@ -212,12 +212,12 @@ visit new_kit_path kit_traits = attributes_for(:kit) - find(:css, '#kit_item_value_in_dollars').set('10.10') + find(:css, '#kit_value_in_dollars').set('10.10') item = Item.last quantity_per_kit = 5 - select item.name, from: "kit_item_line_items_attributes_0_item_id" - find(:css, '#kit_item_line_items_attributes_0_quantity').set(quantity_per_kit) + select item.name, from: "kit_line_items_attributes_0_item_id" + find(:css, '#kit_line_items_attributes_0_quantity').set(quantity_per_kit) click_button "Save"