Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/controllers/items_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ def index

def new
@item = @team.items.build
@locations = @team.locations.ordered
3.times { @item.kit_components.build }
end

def create
Expand All @@ -44,6 +46,7 @@ def create

def edit
@locations = @team.locations.ordered
@item.kit_components.build while @item.kit_components.size < 3
end

def update
Expand Down Expand Up @@ -198,7 +201,8 @@ def set_item

def item_params
params.require(:item).permit(:sku, :name, :barcode, :cost, :price,
:item_type, :brand, :location_id)
:item_type, :brand, :location_id,
kit_components_attributes: [ :id, :component_item_id, :quantity, :_destroy ])
end

def trigger_webhook(event, item)
Expand Down
104 changes: 79 additions & 25 deletions app/controllers/stock_transactions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,21 +59,48 @@ def stock_out

params[:items].each do |item_data|
item = @team.items.find(item_data[:id])
requested_qty = BigDecimal(item_data[:quantity].to_s)

if item.kit_components.any?
# Validate all components availability
item.kit_components.each do |kc|
comp = kc.component_item
needed = (requested_qty * BigDecimal(kc.quantity.to_s))
if comp.current_stock.to_d < needed
raise StandardError, "Not enough stock for component #{comp.name} in kit #{item.name}"
end
end

# Validate stock availability
if item.current_stock < item_data[:quantity].to_i
raise StandardError, "Not enough stock for #{item.name}"
end
# Create stock_out for each component
item.kit_components.each do |kc|
comp = kc.component_item
qty = -(requested_qty * BigDecimal(kc.quantity.to_s))
@team.stock_transactions.create!(
item: comp,
transaction_type: "stock_out",
quantity: qty,
source_location: source_location,
notes: "Kit #{item.sku || item.name}: #{params[:notes]}",
user: current_user
)
trigger_stock_webhook("stock.updated", comp)
end
else
# Validate stock availability for simple item
if item.current_stock.to_d < requested_qty
raise StandardError, "Not enough stock for #{item.name}"
end

@team.stock_transactions.create!(
item: item,
transaction_type: "stock_out",
quantity: -item_data[:quantity].to_i, # Make quantity negative for stock out
source_location: source_location,
notes: params[:notes],
user: current_user
)
trigger_stock_webhook("stock.updated", item)
@team.stock_transactions.create!(
item: item,
transaction_type: "stock_out",
quantity: -requested_qty, # Make quantity negative for stock out
source_location: source_location,
notes: params[:notes],
user: current_user
)
trigger_stock_webhook("stock.updated", item)
end
end

render json: { success: true, redirect_url: team_stock_transactions_path(@team) }
Expand Down Expand Up @@ -208,14 +235,14 @@ def create
# Find the location first
location = @team.locations.find(params[:location])

# Process each item as its own transaction record
# Process each item as its own transaction record (expand kits on stock_out)
items_params.each do |item_data|
item_id = item_data[:id]
quantity = item_data[:quantity].to_f
quantity = BigDecimal(item_data[:quantity].to_s)

item = @team.items.find(item_id)

# Create a separate transaction for each item
# Create transactions
if @transaction_type == "adjust"
# For adjust, calculate the difference
difference = quantity - item.current_stock
Expand All @@ -228,15 +255,42 @@ def create
user: current_user
)
elsif @transaction_type == "stock_out"
# For stock out
@team.stock_transactions.create!(
item: item,
transaction_type: "stock_out",
quantity: quantity * -1, # Make negative for stock out
source_location: location,
notes: @notes,
user: current_user
)
if item.kit_components.any?
# Validate all component availability first
item.kit_components.each do |kc|
comp = kc.component_item
needed = (quantity * BigDecimal(kc.quantity.to_s))
if comp.current_stock.to_d < needed
raise StandardError, "Not enough stock for component #{comp.name} in kit #{item.name}"
end
end

# Create stock out for each component
item.kit_components.each do |kc|
comp = kc.component_item
qty = -(quantity * BigDecimal(kc.quantity.to_s))
@team.stock_transactions.create!(
item: comp,
transaction_type: "stock_out",
quantity: qty,
source_location: location,
notes: "Kit #{item.sku || item.name}: #{@notes}",
user: current_user
)
trigger_stock_webhook("stock.updated", comp)
end
else
# Simple item stock out
@team.stock_transactions.create!(
item: item,
transaction_type: "stock_out",
quantity: quantity * -1, # Make negative for stock out
source_location: location,
notes: @notes,
user: current_user
)
trigger_stock_webhook("stock.updated", item)
end
else
# For stock in
@team.stock_transactions.create!(
Expand Down
27 changes: 27 additions & 0 deletions app/models/item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ class Item < ApplicationRecord
belongs_to :team
belongs_to :location, optional: true
has_many :stock_transactions, dependent: :destroy
# Kit relationships
has_many :kit_components, class_name: "ItemComponent", foreign_key: :kit_item_id, dependent: :destroy
has_many :components, through: :kit_components, source: :component_item
has_many :reverse_kit_components, class_name: "ItemComponent", foreign_key: :component_item_id, dependent: :destroy
has_many :used_in_kits, through: :reverse_kit_components, source: :kit_item
accepts_nested_attributes_for :kit_components, allow_destroy: true

validates :name, presence: true
validates :sku, presence: true, uniqueness: { scope: :team_id }
Expand All @@ -46,6 +52,11 @@ class Item < ApplicationRecord
before_validation :generate_sku, on: :create, if: -> { sku.blank? }

def current_stock
# If this item is a kit (has components), derive stock from components
if kit?
return kit_available_stock
end

total = 0
stock_transactions.each do |transaction|
case transaction.transaction_type
Expand Down Expand Up @@ -106,4 +117,20 @@ def generate_sku
.map { |word| word.first(3).upcase }
.join("-")
end

def kit?
kit_components.any?
end

def kit_available_stock
return 0 if kit_components.empty?

# Minimum of (component current stock / required quantity) across all components
kit_components.map do |kc|
comp_stock = BigDecimal(kc.component_item.current_stock.to_s)
req = BigDecimal(kc.quantity.to_s)
next 0 if req <= 0
(comp_stock / req).floor(2)
end.min || 0
end
end
26 changes: 26 additions & 0 deletions app/models/item_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
class ItemComponent < ApplicationRecord
belongs_to :team
belongs_to :kit_item, class_name: "Item"
belongs_to :component_item, class_name: "Item"

validates :quantity, presence: true, numericality: { greater_than: 0 }
validate :kit_and_component_must_belong_to_same_team
validate :kit_and_component_cannot_be_same

private

def kit_and_component_must_belong_to_same_team
return if kit_item.blank? || component_item.blank? || team.blank?
if kit_item.team_id != team_id || component_item.team_id != team_id
errors.add(:base, "Kit and component must belong to the same team")
end
end

def kit_and_component_cannot_be_same
return if kit_item_id.blank? || component_item_id.blank?
if kit_item_id == component_item_id
errors.add(:component_item_id, "cannot be the same as kit item")
end
end
end

Check failure on line 26 in app/models/item_component.rb

View workflow job for this annotation

GitHub Actions / lint

Layout/TrailingEmptyLines: 1 trailing blank lines detected.
43 changes: 42 additions & 1 deletion app/views/items/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,49 @@
</div>
</div>

<%# Kit components section (optional) %>
<%# Render only if the item is/will be a kit (by convention: item_type == 'kit') %>
<% if item.item_type&.downcase == 'kit' || item.kit_components.any? %>
<div class="mt-10 bg-white p-6 rounded-lg border">
<h3 class="text-lg font-medium text-gray-900 mb-4">Kit Components</h3>
<p class="text-sm text-gray-500 mb-4">Select items and quantities that compose this kit.</p>

<div class="space-y-4">
<div class="grid grid-cols-12 gap-4 font-semibold text-gray-600">
<div class="col-span-7">Item</div>
<div class="col-span-3">Quantity</div>
<div class="col-span-2"></div>
</div>

<div class="space-y-3">
<%= f.fields_for :kit_components do |kc| %>
<div class="grid grid-cols-12 gap-4 items-center">
<div class="col-span-7">
<%= kc.collection_select :component_item_id,
@team.items.where.not(id: item.id).order(:name),
:id, :name,
{ include_blank: 'Select item' },
class: 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm' %>
</div>
<div class="col-span-3">
<%= kc.number_field :quantity, step: '0.01', min: 0.01,
class: 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm' %>
</div>
<div class="col-span-2">
<label class="inline-flex items-center space-x-2 text-sm">
<%= kc.check_box :_destroy %>
<span>Remove</span>
</label>
</div>
</div>
<% end %>
</div>
</div>
</div>
<% end %>

<div class="flex justify-end space-x-3 mt-6">
<%= link_to t('items.form.buttons.cancel'), team_items_path(@team), class: "btn btn-secondary" %>
<%= f.submit class: "btn btn-primary" %>
</div>
<% end %>
<% end %>
15 changes: 15 additions & 0 deletions db/migrate/20250915090000_create_item_components.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class CreateItemComponents < ActiveRecord::Migration[8.0]
def change
create_table :item_components do |t|
t.references :team, null: false, foreign_key: true
t.references :kit_item, null: false, foreign_key: { to_table: :items }
t.references :component_item, null: false, foreign_key: { to_table: :items }
t.decimal :quantity, precision: 10, scale: 2, null: false

t.timestamps
end

add_index :item_components, [:kit_item_id, :component_item_id], unique: true

Check failure on line 12 in db/migrate/20250915090000_create_item_components.rb

View workflow job for this annotation

GitHub Actions / lint

Layout/SpaceInsideArrayLiteralBrackets: Use space inside array brackets.

Check failure on line 12 in db/migrate/20250915090000_create_item_components.rb

View workflow job for this annotation

GitHub Actions / lint

Layout/SpaceInsideArrayLiteralBrackets: Use space inside array brackets.
end
end

Check failure on line 15 in db/migrate/20250915090000_create_item_components.rb

View workflow job for this annotation

GitHub Actions / lint

Layout/TrailingEmptyLines: 1 trailing blank lines detected.
Loading