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 @@ +
| ID | +Supplier | +Order # | +Required By | +Status | +Created By | +Created 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") %> | +
No purchase orders found.
+| SKU | +Quantity | +Unit Cost | +Line 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) %> | +||