diff --git a/app/controllers/warehouse/purchase_orders_controller.rb b/app/controllers/warehouse/purchase_orders_controller.rb new file mode 100644 index 0000000..a3f613a --- /dev/null +++ b/app/controllers/warehouse/purchase_orders_controller.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +class Warehouse::PurchaseOrdersController < ApplicationController + before_action :authenticate_user! + before_action :set_purchase_order, except: %i[new create index] + + def index + authorize Warehouse::PurchaseOrder + @purchase_orders = policy_scope(Warehouse::PurchaseOrder).includes(:user, :line_items).order(created_at: :desc) + end + + def show + authorize @purchase_order + end + + def new + authorize Warehouse::PurchaseOrder + @purchase_order = Warehouse::PurchaseOrder.new + @purchase_order.line_items.build + end + + def create + @purchase_order = Warehouse::PurchaseOrder.new( + purchase_order_params.merge(user: current_user) + ) + authorize @purchase_order + + if @purchase_order.save + redirect_to warehouse_purchase_order_path(@purchase_order), notice: "Purchase order was successfully created." + else + render :new, status: :unprocessable_entity + end + end + + def edit + authorize @purchase_order + end + + def update + authorize @purchase_order + + if @purchase_order.update(purchase_order_params) + redirect_to warehouse_purchase_order_path(@purchase_order), notice: "Purchase order was successfully updated." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + authorize @purchase_order + + @purchase_order.destroy! + redirect_to warehouse_purchase_orders_path, status: :see_other, notice: "Purchase order was deleted." + end + + def send_to_zenventory + authorize @purchase_order + + begin + @purchase_order.dispatch! + rescue Zenventory::ZenventoryError => e + event_id = Sentry.capture_exception(e)&.event_id + redirect_to warehouse_purchase_order_path(@purchase_order), alert: "Zenventory said \"#{e.message}\" (error: #{event_id})" + return + rescue AASM::InvalidTransition => e + event_id = Sentry.capture_exception(e)&.event_id + redirect_to warehouse_purchase_order_path(@purchase_order), alert: "Couldn't dispatch purchase order! Wrong state? (error: #{event_id})" + return + end + + redirect_to warehouse_purchase_order_path(@purchase_order), flash: { success: "Successfully sent to Zenventory!" } + end + + def sync + authorize @purchase_order + + begin + @purchase_order.sync_from_zenventory! + rescue Zenventory::ZenventoryError => e + event_id = Sentry.capture_exception(e)&.event_id + redirect_to warehouse_purchase_order_path(@purchase_order), alert: "Zenventory said \"#{e.message}\" (error: #{event_id})" + return + end + + redirect_to warehouse_purchase_order_path(@purchase_order), flash: { success: "Synced from Zenventory!" } + end + + private + + def set_purchase_order + @purchase_order = Warehouse::PurchaseOrder.find(params[:id]) + end + + def purchase_order_params + params.require(:warehouse_purchase_order).permit( + :supplier_name, + :supplier_id, + :notes, + :required_by_date, + line_items_attributes: %i[id sku_id quantity unit_cost _destroy] + ) + end +end diff --git a/app/helpers/warehouse/purchase_orders_helper.rb b/app/helpers/warehouse/purchase_orders_helper.rb new file mode 100644 index 0000000..4350c12 --- /dev/null +++ b/app/helpers/warehouse/purchase_orders_helper.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +module Warehouse::PurchaseOrdersHelper +end diff --git a/app/models/warehouse/purchase_order.rb b/app/models/warehouse/purchase_order.rb new file mode 100644 index 0000000..d797e4a --- /dev/null +++ b/app/models/warehouse/purchase_order.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: warehouse_purchase_orders +# +# id :bigint not null, primary key +# notes :text +# order_number :string +# required_by_date :date +# status :string default("draft") +# supplier_name :string +# created_at :datetime not null +# updated_at :datetime not null +# supplier_id :integer +# user_id :bigint not null +# zenventory_id :integer +# +# Indexes +# +# index_warehouse_purchase_orders_on_order_number (order_number) +# index_warehouse_purchase_orders_on_user_id (user_id) +# index_warehouse_purchase_orders_on_zenventory_id (zenventory_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (user_id => users.id) +# +class Warehouse::PurchaseOrder < ApplicationRecord + include AASM + include HasZenventoryUrl + + belongs_to :user + has_many :line_items, class_name: "Warehouse::PurchaseOrderLineItem", foreign_key: :purchase_order_id, dependent: :destroy, inverse_of: :purchase_order + + accepts_nested_attributes_for :line_items, allow_destroy: true, reject_if: :all_blank + + validates :supplier_name, presence: true + validates :line_items, presence: true + + has_zenventory_url "https://app.zenventory.com/purchasing/purchase-order/%s", :zenventory_id + + HUMANIZED_STATES = { + draft: "Draft", + open: "Open", + completed: "Completed", + deleted: "Deleted" + }.freeze + + def humanized_state + HUMANIZED_STATES[status&.to_sym] || status + end + + aasm column: :status, timestamps: true do + state :draft, initial: true + state :open + state :completed + state :deleted + + event :mark_open do + transitions from: :draft, to: :open + end + + event :mark_completed do + transitions from: :open, to: :completed + end + + event :mark_deleted do + transitions from: %i[draft open], to: :deleted + end + end + + def draft? + status == "draft" + end + + def open? + status == "open" + end + + def completed? + status == "completed" + end + + def dispatch! + ActiveRecord::Base.transaction do + raise AASM::InvalidTransition, "wrong state" unless may_mark_open? + + po_params = { + supplier: { id: supplier_id, name: supplier_name }.compact, + requiredByDate: required_by_date&.iso8601, + notes: notes, + items: line_items.map do |li| + { + sku: li.sku.sku, + quantity: li.quantity, + unitCost: li.unit_cost&.to_f + }.compact + end + }.compact + + response = Zenventory.create_purchase_order(po_params) + update!(zenventory_id: response[:id], order_number: response[:orderNumber]) + mark_open! + end + end + + def sync_from_zenventory! + return unless zenventory_id.present? + + zenv_po = Zenventory.get_purchase_order(zenventory_id) + + self.supplier_name = zenv_po.dig(:supplier, :name) || supplier_name + self.supplier_id = zenv_po.dig(:supplier, :id) || supplier_id + self.order_number = zenv_po[:orderNumber] || order_number + self.notes = zenv_po[:notes] if zenv_po[:notes].present? + self.required_by_date = Date.parse(zenv_po[:requiredByDate]) if zenv_po[:requiredByDate].present? + + zenv_status = zenv_po[:status]&.downcase + self.status = zenv_status if %w[draft open completed deleted].include?(zenv_status) + + save! + end + + def total_cost + line_items.sum { |li| (li.unit_cost || 0) * li.quantity } + end +end diff --git a/app/models/warehouse/purchase_order_line_item.rb b/app/models/warehouse/purchase_order_line_item.rb new file mode 100644 index 0000000..f1f04e0 --- /dev/null +++ b/app/models/warehouse/purchase_order_line_item.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: warehouse_purchase_order_line_items +# +# id :bigint not null, primary key +# quantity :integer not null +# unit_cost :decimal(10, 2) +# created_at :datetime not null +# updated_at :datetime not null +# purchase_order_id :bigint not null +# sku_id :bigint not null +# +# Indexes +# +# index_warehouse_purchase_order_line_items_on_purchase_order_id (purchase_order_id) +# index_warehouse_purchase_order_line_items_on_sku_id (sku_id) +# +# Foreign Keys +# +# fk_rails_... (purchase_order_id => warehouse_purchase_orders.id) +# fk_rails_... (sku_id => warehouse_skus.id) +# +class Warehouse::PurchaseOrderLineItem < ApplicationRecord + belongs_to :purchase_order, class_name: "Warehouse::PurchaseOrder", inverse_of: :line_items + belongs_to :sku, class_name: "Warehouse::SKU" + + validates :quantity, presence: true, numericality: { greater_than: 0 } +end diff --git a/app/policies/warehouse/purchase_order_policy.rb b/app/policies/warehouse/purchase_order_policy.rb new file mode 100644 index 0000000..c05b2d9 --- /dev/null +++ b/app/policies/warehouse/purchase_order_policy.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class Warehouse::PurchaseOrderPolicy < ApplicationPolicy + def index? + user_is_admin + end + + def show? + user_is_admin + end + + def new? + user_is_admin + end + + def create? + user_is_admin + end + + def edit? + user_is_admin + end + + def update? + user_is_admin + end + + def destroy? + user_is_admin && record.draft? + end + + def send_to_zenventory? + user_is_admin && record.draft? + end + + def sync? + user_is_admin && record.zenventory_id.present? + end + + class Scope < ApplicationPolicy::Scope + def resolve + if user&.admin? + scope.all + else + scope.none + end + end + end +end diff --git a/app/services/zenventory.rb b/app/services/zenventory.rb index a61f6dd..e1dfd64 100644 --- a/app/services/zenventory.rb +++ b/app/services/zenventory.rb @@ -82,14 +82,26 @@ class Zenventory conn.post("purchase-orders", **params).body end + def draft_purchase_order(params = {}) + create_purchase_order(draft: true, **params) + end + def update_purchase_order(id, params = {}) conn.put("purchase-orders/#{id}", **params).body end + def finalize_purchase_order(id, params = {}) + update_purchase_order(id, draft: false, **params) + end + def close_purchase_order(id) conn.put("purchase-orders/#{id}/close").body end + def get_suppliers + get_purchase_orders.map { |po| po[:supplier] }.compact.uniq { |s| s[:id] } + end + def run_report(category, report_key, params = {}) CSV.parse( conn.get("reports/#{category}/#{report_key}", csv: true, **params).body, diff --git a/app/views/shared/_nav.html.erb b/app/views/shared/_nav.html.erb index 0d90d33..3fd58ba 100644 --- a/app/views/shared/_nav.html.erb +++ b/app/views/shared/_nav.html.erb @@ -36,6 +36,7 @@ <%= nav_item(warehouse_batches_path, "Batches") %> <%= nav_item(warehouse_skus_path, "SKUs") %> <%= nav_item(warehouse_orders_path, "Orders") %> + <%= nav_item(warehouse_purchase_orders_path, "Purchase Orders") %> <%= nav_item(warehouse_templates_path, "Order templates") %> diff --git a/app/views/warehouse/purchase_orders/_form.html.slim b/app/views/warehouse/purchase_orders/_form.html.slim new file mode 100644 index 0000000..e719f39 --- /dev/null +++ b/app/views/warehouse/purchase_orders/_form.html.slim @@ -0,0 +1,38 @@ +- content_for :head + = vite_javascript_tag 'cocoon' + += form_with(model: purchase_order, url: purchase_order.persisted? ? warehouse_purchase_order_path(purchase_order) : warehouse_purchase_orders_path) do |form| + - if purchase_order.errors.any? + = render layout: 'shared/banner', locals: {color: 'alert'} do + b hey, slight issue: + ul + - purchase_order.errors.each do |error| + li= error.full_message + + div + = form.label :supplier_name, "Supplier Name:" + = form.text_field :supplier_name, class: 'form-control', required: true + + div + = form.label :supplier_id, "Supplier ID (from Zenventory, optional):" + = form.number_field :supplier_id, class: 'form-control' + + div + = form.label :required_by_date, "Required By Date:" + = form.date_field :required_by_date, class: 'form-control' + + .py-3 + = form.label :notes, "Notes:" + .display-block + = form.text_area :notes, class: 'form-control', rows: 4 + + h4 Line Items: + #line_items + = form.fields_for :line_items do |line_item| + = render 'line_item_fields', f: line_item + + .links + = link_to_add_association 'Add item', form, :line_items + + .pt-3 + = form.submit "Save", class: 'form-control' diff --git a/app/views/warehouse/purchase_orders/_line_item_fields.html.erb b/app/views/warehouse/purchase_orders/_line_item_fields.html.erb new file mode 100644 index 0000000..ceca33c --- /dev/null +++ b/app/views/warehouse/purchase_orders/_line_item_fields.html.erb @@ -0,0 +1,32 @@ +
+ <% skus_by_category = Warehouse::SKU.order(:sku).group_by(&:category) %> + +
+
+ <%= f.grouped_collection_select :sku_id, + skus_by_category, + :last, + lambda { |group| group.first.humanize }, + :id, + -> (sku) { "#{sku.sku} - #{sku.name}"}, + { include_blank: 'Select a SKU' }, + { class: 'needs-select2', disabled: f.object.persisted? } %> +
+ +
+ <%= f.number_field :quantity, class: 'form-control', min: 1, placeholder: 'Quantity' %> +
+ +
+ <%= f.number_field :unit_cost, class: 'form-control', min: 0, step: 0.01, placeholder: 'Unit Cost' %> +
+ +
+ <%= link_to_remove_association "Remove", f, class: 'secondary outline' %> +
+
+ + <% if f.object.persisted? %> + <%= f.hidden_field :sku_id %> + <% end %> +
diff --git a/app/views/warehouse/purchase_orders/_status_badge.html.erb b/app/views/warehouse/purchase_orders/_status_badge.html.erb new file mode 100644 index 0000000..e5c3c41 --- /dev/null +++ b/app/views/warehouse/purchase_orders/_status_badge.html.erb @@ -0,0 +1,18 @@ +<% + color, text = case purchase_order.status&.to_sym + when :draft + %w[bg-muted draft] + when :open + ["info", "open"] + when :completed + %w[success completed] + when :deleted + %w[warning deleted] + else + ["purple", purchase_order.status || "unknown"] + end +%> + + + <%= text %> + diff --git a/app/views/warehouse/purchase_orders/edit.html.erb b/app/views/warehouse/purchase_orders/edit.html.erb new file mode 100644 index 0000000..5e16326 --- /dev/null +++ b/app/views/warehouse/purchase_orders/edit.html.erb @@ -0,0 +1,12 @@ +<% content_for :title, "Edit Purchase Order" %> + +

Edit Purchase Order #<%= @purchase_order.id %>

+ +<%= render "form", purchase_order: @purchase_order %> + +
+ +
+ <%= link_to "Show this purchase order", warehouse_purchase_order_path(@purchase_order) %> | + <%= link_to "Back to purchase orders", warehouse_purchase_orders_path %> +
diff --git a/app/views/warehouse/purchase_orders/index.html.erb b/app/views/warehouse/purchase_orders/index.html.erb new file mode 100644 index 0000000..32c0acd --- /dev/null +++ b/app/views/warehouse/purchase_orders/index.html.erb @@ -0,0 +1,58 @@ +<% content_for :title, "Purchase Orders" %> + + +
+
+
+

All Purchase Orders

+
+
+ <% if @purchase_orders.any? %> + + + + + + + + + + + + + + <% @purchase_orders.each do |po| %> + + + + + + + + + + <% end %> + +
IDSupplierOrder #Required ByStatusCreated ByCreated At
+ <%= link_to po.id, warehouse_purchase_order_path(po) %> + <%= po.supplier_name %><%= po.order_number %><%= po.required_by_date&.strftime("%b %d, %Y") %> + <%= render 'status_badge', purchase_order: po %> + <%= po.user&.email %><%= po.created_at.strftime("%b %d, %Y") %>
+ <% else %> +
+

No purchase orders found.

+
+ <% end %> +
+
+
diff --git a/app/views/warehouse/purchase_orders/new.html.erb b/app/views/warehouse/purchase_orders/new.html.erb new file mode 100644 index 0000000..f9f9475 --- /dev/null +++ b/app/views/warehouse/purchase_orders/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "New Purchase Order" %> + +

New Purchase Order

+ +<%= render "form", purchase_order: @purchase_order %> + +
+ +
+ <%= link_to "Back to purchase orders", warehouse_purchase_orders_path %> +
diff --git a/app/views/warehouse/purchase_orders/show.html.erb b/app/views/warehouse/purchase_orders/show.html.erb new file mode 100644 index 0000000..d560ba6 --- /dev/null +++ b/app/views/warehouse/purchase_orders/show.html.erb @@ -0,0 +1,86 @@ +<% content_for :title, "Purchase Order ##{@purchase_order.id}" %> + +
+

+ Purchase Order #<%= @purchase_order.id %> +

+ <%= render 'status_badge', purchase_order: @purchase_order %> +
+ +
+ Supplier: <%= @purchase_order.supplier_name %>
+ <% if @purchase_order.supplier_id.present? %> + Supplier ID: <%= @purchase_order.supplier_id %>
+ <% end %> + <% if @purchase_order.order_number.present? %> + Order Number: <%= @purchase_order.order_number %>
+ <% end %> + <% if @purchase_order.required_by_date.present? %> + Required By: <%= @purchase_order.required_by_date.strftime("%B %d, %Y") %>
+ <% end %> + Created By: <%= link_to @purchase_order.user&.email, @purchase_order.user %>
+ Created At: <%= @purchase_order.created_at.strftime("%B %d, %Y at %I:%M %p") %>
+
+ +<% if @purchase_order.notes.present? %> +
+ Notes:
+ <%= simple_format(@purchase_order.notes) %> +
+<% end %> + +
+ Line Items: + + + + + + + + + + + <% @purchase_order.line_items.each do |li| %> + + + + + + + <% end %> + + + + + +
SKUQuantityUnit CostLine Total
+ <%= link_to li.sku.name, warehouse_sku_path(li.sku), target: '_blank' %> + (<%= li.sku.sku %>) + <%= li.quantity %><%= number_to_currency(li.unit_cost) %><%= number_to_currency((li.unit_cost || 0) * li.quantity) %>
Total:<%= number_to_currency(@purchase_order.total_cost) %>
+
+ +<% zenv_link @purchase_order %> + +
+ <% if policy(@purchase_order).send_to_zenventory? && @purchase_order.may_mark_open? %> + <%= button_to "Send to Zenventory", send_to_zenventory_warehouse_purchase_order_path(@purchase_order), method: :post %> + <% end %> + + <% if policy(@purchase_order).sync? %> + <%= button_to "Sync from Zenventory", sync_warehouse_purchase_order_path(@purchase_order), method: :post %> + <% end %> + + <% if policy(@purchase_order).destroy? %> + <%= button_to "Delete", warehouse_purchase_order_path(@purchase_order), method: :delete, data: { confirm: "Are you sure?" } %> + <% end %> +
+ +
+ <% if policy(@purchase_order).edit? %> + <%= link_to "Edit this purchase order", edit_warehouse_purchase_order_path(@purchase_order) %>
+ <% end %> + <%= link_to "Back to purchase orders", warehouse_purchase_orders_path %> +
+ +<%= render partial: "admin_inspector", locals: { record: @purchase_order } %> diff --git a/config/routes.rb b/config/routes.rb index 2a34eab..d9b7f82 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -550,6 +550,12 @@ Rails.application.routes.draw do post "send_to_warehouse" end end + resources :purchase_orders do + member do + post :send_to_zenventory + post :sync + end + end resources :batches do member do get "/map", to: "batches#map_fields", as: :map_fields diff --git a/db/migrate/20260115180433_create_warehouse_purchase_orders.rb b/db/migrate/20260115180433_create_warehouse_purchase_orders.rb new file mode 100644 index 0000000..6d563f6 --- /dev/null +++ b/db/migrate/20260115180433_create_warehouse_purchase_orders.rb @@ -0,0 +1,19 @@ +class CreateWarehousePurchaseOrders < ActiveRecord::Migration[8.0] + def change + create_table :warehouse_purchase_orders do |t| + t.string :supplier_name + t.integer :supplier_id + t.string :order_number + t.text :notes + t.date :required_by_date + t.string :status, default: "draft" + t.integer :zenventory_id + t.references :user, null: false, foreign_key: true + + t.timestamps + end + + add_index :warehouse_purchase_orders, :zenventory_id, unique: true + add_index :warehouse_purchase_orders, :order_number + end +end diff --git a/db/migrate/20260115180434_create_warehouse_purchase_order_line_items.rb b/db/migrate/20260115180434_create_warehouse_purchase_order_line_items.rb new file mode 100644 index 0000000..2dbd361 --- /dev/null +++ b/db/migrate/20260115180434_create_warehouse_purchase_order_line_items.rb @@ -0,0 +1,12 @@ +class CreateWarehousePurchaseOrderLineItems < ActiveRecord::Migration[8.0] + def change + create_table :warehouse_purchase_order_line_items do |t| + t.references :purchase_order, null: false, foreign_key: { to_table: :warehouse_purchase_orders } + t.references :sku, null: false, foreign_key: { to_table: :warehouse_skus } + t.integer :quantity, null: false + t.decimal :unit_cost, precision: 10, scale: 2 + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 33b8f58..1bcdd5b 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: 2025_12_18_194622) do +ActiveRecord::Schema[8.0].define(version: 2026_01_15_180434) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "pg_catalog.plpgsql" @@ -566,6 +566,33 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_18_194622) do t.index ["user_id"], name: "index_warehouse_orders_on_user_id" end + create_table "warehouse_purchase_order_line_items", force: :cascade do |t| + t.bigint "purchase_order_id", null: false + t.bigint "sku_id", null: false + t.integer "quantity", null: false + t.decimal "unit_cost", precision: 10, scale: 2 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["purchase_order_id"], name: "index_warehouse_purchase_order_line_items_on_purchase_order_id" + t.index ["sku_id"], name: "index_warehouse_purchase_order_line_items_on_sku_id" + end + + create_table "warehouse_purchase_orders", force: :cascade do |t| + t.string "supplier_name" + t.integer "supplier_id" + t.string "order_number" + t.text "notes" + t.date "required_by_date" + t.string "status", default: "draft" + t.integer "zenventory_id" + t.bigint "user_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["order_number"], name: "index_warehouse_purchase_orders_on_order_number" + t.index ["user_id"], name: "index_warehouse_purchase_orders_on_user_id" + t.index ["zenventory_id"], name: "index_warehouse_purchase_orders_on_zenventory_id", unique: true + end + create_table "warehouse_skus", force: :cascade do |t| t.string "sku" t.text "description" @@ -643,6 +670,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_18_194622) do add_foreign_key "warehouse_orders", "source_tags" add_foreign_key "warehouse_orders", "users" add_foreign_key "warehouse_orders", "warehouse_templates", column: "template_id" + add_foreign_key "warehouse_purchase_order_line_items", "warehouse_purchase_orders", column: "purchase_order_id" + add_foreign_key "warehouse_purchase_order_line_items", "warehouse_skus", column: "sku_id" + add_foreign_key "warehouse_purchase_orders", "users" add_foreign_key "warehouse_templates", "source_tags" add_foreign_key "warehouse_templates", "users" end