This commit is contained in:
24c02 2026-01-15 18:10:35 -05:00
parent 433597368b
commit b544ee4653
18 changed files with 650 additions and 1 deletions

View file

@ -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

View file

@ -0,0 +1,4 @@
# frozen_string_literal: true
module Warehouse::PurchaseOrdersHelper
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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") %>
</ul>
</details>

View file

@ -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'

View file

@ -0,0 +1,32 @@
<div class="nested-fields grid">
<% skus_by_category = Warehouse::SKU.order(:sku).group_by(&:category) %>
<div class="grid">
<div class="col-12 md:col-5">
<%= 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? } %>
</div>
<div class="col-12 md:col-2">
<%= f.number_field :quantity, class: 'form-control', min: 1, placeholder: 'Quantity' %>
</div>
<div class="col-12 md:col-3">
<%= f.number_field :unit_cost, class: 'form-control', min: 0, step: 0.01, placeholder: 'Unit Cost' %>
</div>
<div class="col-12 md:col-2">
<%= link_to_remove_association "Remove", f, class: 'secondary outline' %>
</div>
</div>
<% if f.object.persisted? %>
<%= f.hidden_field :sku_id %>
<% end %>
</div>

View file

@ -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
%>
<span class="badge <%= color %> float-right">
<%= text %>
</span>

View file

@ -0,0 +1,12 @@
<% content_for :title, "Edit Purchase Order" %>
<h1>Edit Purchase Order #<%= @purchase_order.id %></h1>
<%= render "form", purchase_order: @purchase_order %>
<br>
<div>
<%= link_to "Show this purchase order", warehouse_purchase_order_path(@purchase_order) %> |
<%= link_to "Back to purchase orders", warehouse_purchase_orders_path %>
</div>

View file

@ -0,0 +1,58 @@
<% content_for :title, "Purchase Orders" %>
<div class="page-header mb-8 flex justify-between items-center">
<h1 class="text-2xl font-bold">Purchase Orders</h1>
<div class="actions">
<%= success_link_to "Create New Purchase Order", new_warehouse_purchase_order_path do %>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Create New Purchase Order
<% end %>
</div>
</div>
<div class="orders-container">
<div class="bg-gray-100 rounded-lg border border-gray-200">
<div class="border-b border-gray-200 p-4">
<h2 class="text-xl font-medium">All Purchase Orders</h2>
</div>
<div class="p-6">
<% if @purchase_orders.any? %>
<table class="table-fixed w-full text-left border border-gray-300 border-collapse">
<thead>
<tr>
<th class="border border-gray-300 p-2">ID</th>
<th class="border border-gray-300 p-2">Supplier</th>
<th class="border border-gray-300 p-2">Order #</th>
<th class="border border-gray-300 p-2">Required By</th>
<th class="border border-gray-300 p-2">Status</th>
<th class="border border-gray-300 p-2">Created By</th>
<th class="border border-gray-300 p-2">Created At</th>
</tr>
</thead>
<tbody>
<% @purchase_orders.each do |po| %>
<tr>
<td class="border border-gray-400 p-2">
<%= link_to po.id, warehouse_purchase_order_path(po) %>
</td>
<td class="border border-gray-400 p-2"><%= po.supplier_name %></td>
<td class="border border-gray-400 p-2"><%= po.order_number %></td>
<td class="border border-gray-400 p-2"><%= po.required_by_date&.strftime("%b %d, %Y") %></td>
<td class="border border-gray-400 p-2">
<%= render 'status_badge', purchase_order: po %>
</td>
<td class="border border-gray-400 p-2"><%= po.user&.email %></td>
<td class="border border-gray-400 p-2"><%= po.created_at.strftime("%b %d, %Y") %></td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<div class="text-center py-8">
<p class="text-gray-500">No purchase orders found.</p>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -0,0 +1,11 @@
<% content_for :title, "New Purchase Order" %>
<h1>New Purchase Order</h1>
<%= render "form", purchase_order: @purchase_order %>
<br>
<div>
<%= link_to "Back to purchase orders", warehouse_purchase_orders_path %>
</div>

View file

@ -0,0 +1,86 @@
<% content_for :title, "Purchase Order ##{@purchase_order.id}" %>
<div>
<h2 class="inline">
Purchase Order #<%= @purchase_order.id %>
</h2>
<%= render 'status_badge', purchase_order: @purchase_order %>
</div>
<div class="my-4">
<b>Supplier:</b> <%= @purchase_order.supplier_name %><br>
<% if @purchase_order.supplier_id.present? %>
<b>Supplier ID:</b> <%= @purchase_order.supplier_id %><br>
<% end %>
<% if @purchase_order.order_number.present? %>
<b>Order Number:</b> <%= @purchase_order.order_number %><br>
<% end %>
<% if @purchase_order.required_by_date.present? %>
<b>Required By:</b> <%= @purchase_order.required_by_date.strftime("%B %d, %Y") %><br>
<% end %>
<b>Created By:</b> <%= link_to @purchase_order.user&.email, @purchase_order.user %><br>
<b>Created At:</b> <%= @purchase_order.created_at.strftime("%B %d, %Y at %I:%M %p") %><br>
</div>
<% if @purchase_order.notes.present? %>
<div class="my-4">
<b>Notes:</b><br>
<%= simple_format(@purchase_order.notes) %>
</div>
<% end %>
<div class="my-4">
<b>Line Items:</b>
<table class="table-fixed text-left border border-gray-300 border-collapse mt-2">
<thead>
<tr>
<th class="border border-gray-300 p-2">SKU</th>
<th class="border border-gray-300 p-2">Quantity</th>
<th class="border border-gray-300 p-2">Unit Cost</th>
<th class="border border-gray-300 p-2">Line Total</th>
</tr>
</thead>
<tbody>
<% @purchase_order.line_items.each do |li| %>
<tr>
<td class="border border-gray-400 p-2">
<%= link_to li.sku.name, warehouse_sku_path(li.sku), target: '_blank' %>
<span class="text-gray-500">(<%= li.sku.sku %>)</span>
</td>
<td class="border border-gray-400 p-2"><%= li.quantity %></td>
<td class="border border-gray-400 p-2"><%= number_to_currency(li.unit_cost) %></td>
<td class="border border-gray-400 p-2"><%= number_to_currency((li.unit_cost || 0) * li.quantity) %></td>
</tr>
<% end %>
<tr>
<td colspan="3" class="border border-gray-400 p-2 text-right"><b>Total:</b></td>
<td class="border border-gray-400 p-2"><b><%= number_to_currency(@purchase_order.total_cost) %></b></td>
</tr>
</tbody>
</table>
</div>
<% zenv_link @purchase_order %>
<div class="my-4 space-x-2">
<% 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 %>
</div>
<div>
<% if policy(@purchase_order).edit? %>
<%= link_to "Edit this purchase order", edit_warehouse_purchase_order_path(@purchase_order) %><br>
<% end %>
<%= link_to "Back to purchase orders", warehouse_purchase_orders_path %>
</div>
<%= render partial: "admin_inspector", locals: { record: @purchase_order } %>

View file

@ -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

View file

@ -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

View file

@ -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

32
db/schema.rb generated
View file

@ -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