mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-03-05 02:41:33 +00:00
Merge pull request #2869 from Matt-Yorkley/bi/bulk_invoices
[Bulk Invoice Printing] Bulk invoices
This commit is contained in:
1
Gemfile
1
Gemfile
@@ -72,6 +72,7 @@ gem 'paper_trail', '~> 5.2.3'
|
||||
gem 'diffy'
|
||||
gem 'skylight', '< 2.0'
|
||||
|
||||
gem 'combine_pdf'
|
||||
gem 'wicked_pdf'
|
||||
gem 'wkhtmltopdf-binary'
|
||||
|
||||
|
||||
@@ -234,6 +234,8 @@ GEM
|
||||
execjs
|
||||
coffee-script-source (1.10.0)
|
||||
colorize (0.8.1)
|
||||
combine_pdf (1.0.15)
|
||||
ruby-rc4 (>= 0.1.5)
|
||||
compass (1.0.3)
|
||||
chunky_png (~> 1.2)
|
||||
compass-core (~> 1.0.2)
|
||||
@@ -677,6 +679,7 @@ GEM
|
||||
unicode-display_width (~> 1.0, >= 1.0.1)
|
||||
ruby-ole (1.2.12.1)
|
||||
ruby-progressbar (1.10.0)
|
||||
ruby-rc4 (0.1.5)
|
||||
rubyzip (1.2.2)
|
||||
safe_yaml (1.0.4)
|
||||
sass (3.3.14)
|
||||
@@ -778,6 +781,7 @@ DEPENDENCIES
|
||||
capybara (>= 2.15.4)
|
||||
chromedriver-helper
|
||||
coffee-rails (~> 3.2.1)
|
||||
combine_pdf
|
||||
compass-rails
|
||||
custom_error_message!
|
||||
daemons
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
angular.module("admin.orders").controller "bulkInvoiceCtrl", ($scope, $http, $timeout) ->
|
||||
$scope.createBulkInvoice = ->
|
||||
$scope.invoice_id = null
|
||||
$scope.poll = 1
|
||||
$scope.loading = true
|
||||
$scope.message = null
|
||||
$scope.error = null
|
||||
$scope.poll_wait = 5 # 5 Seconds between each check
|
||||
$scope.poll_retries = 80 # Maximum checks before stopping
|
||||
|
||||
$http.post('/admin/orders/invoices', {order_ids: $scope.selected_orders}).success (data) ->
|
||||
$scope.invoice_id = data
|
||||
$scope.pollBulkInvoice()
|
||||
|
||||
$scope.pollBulkInvoice = ->
|
||||
$timeout($scope.nextPoll, $scope.poll_wait * 1000)
|
||||
|
||||
$scope.nextPoll = ->
|
||||
$http.get('/admin/orders/invoices/'+$scope.invoice_id+'/poll').success (data) ->
|
||||
$scope.loading = false
|
||||
$scope.message = t('js.admin.orders.index.bulk_invoice_created')
|
||||
|
||||
.error (data) ->
|
||||
$scope.poll++
|
||||
|
||||
if $scope.poll > $scope.poll_retries
|
||||
$scope.loading = false
|
||||
$scope.error = t('js.admin.orders.index.bulk_invoice_failed')
|
||||
return
|
||||
|
||||
$scope.pollBulkInvoice()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
angular.module("admin.orders").controller "ordersCtrl", ($scope, RequestMonitor, Orders, SortOptions) ->
|
||||
angular.module("admin.orders").controller "ordersCtrl", ($scope, RequestMonitor, Orders, SortOptions, $window, $filter) ->
|
||||
$scope.RequestMonitor = RequestMonitor
|
||||
$scope.pagination = Orders.pagination
|
||||
$scope.orders = Orders.all
|
||||
@@ -8,6 +8,11 @@ angular.module("admin.orders").controller "ordersCtrl", ($scope, RequestMonitor,
|
||||
{id: 50, name: t('js.admin.orders.index.per_page', results: 50)},
|
||||
{id: 100, name: t('js.admin.orders.index.per_page', results: 100)}
|
||||
]
|
||||
$scope.selected_orders = []
|
||||
$scope.checkboxes = {}
|
||||
$scope.selected = false
|
||||
$scope.select_all = false
|
||||
$scope.poll = 0
|
||||
|
||||
$scope.initialise = ->
|
||||
$scope.per_page = 15
|
||||
@@ -17,6 +22,7 @@ angular.module("admin.orders").controller "ordersCtrl", ($scope, RequestMonitor,
|
||||
$scope.fetchResults()
|
||||
|
||||
$scope.fetchResults = (page=1) ->
|
||||
$scope.resetSelected()
|
||||
Orders.index({
|
||||
'q[completed_at_lt]': $scope['q']['completed_at_lt'],
|
||||
'q[completed_at_gt]': $scope['q']['completed_at_gt'],
|
||||
@@ -36,6 +42,26 @@ angular.module("admin.orders").controller "ordersCtrl", ($scope, RequestMonitor,
|
||||
page: page
|
||||
})
|
||||
|
||||
$scope.resetSelected = ->
|
||||
$scope.selected_orders.length = 0
|
||||
$scope.selected = false
|
||||
$scope.select_all = false
|
||||
$scope.checkboxes = {}
|
||||
|
||||
$scope.toggleSelection = (id) ->
|
||||
index = $scope.selected_orders.indexOf(id)
|
||||
|
||||
if index == -1
|
||||
$scope.selected_orders.push(id)
|
||||
else
|
||||
$scope.selected_orders.splice(index, 1)
|
||||
|
||||
$scope.toggleAll = ->
|
||||
$scope.selected_orders.length = 0
|
||||
$scope.orders.forEach (order) ->
|
||||
$scope.checkboxes[order.id] = $scope.select_all
|
||||
$scope.selected_orders.push order.id if $scope.select_all
|
||||
|
||||
$scope.$watch 'sortOptions', (sort) ->
|
||||
if sort && sort.predicate != ""
|
||||
$scope.sorting = sort.predicate + ' desc' if sort.reverse
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
angular.module("admin.orders").directive "invoicesModal", ($modal) ->
|
||||
restrict: 'C'
|
||||
link: (scope, elem, attrs, ctrl) ->
|
||||
elem.on "click", (ev) =>
|
||||
scope.uploadModal = $modal.open(templateUrl: 'admin/modals/bulk_invoice.html', controller: ctrl, scope: scope, windowClass: 'simple-modal')
|
||||
@@ -1 +1 @@
|
||||
angular.module("admin.orders", ['admin.indexUtils', 'ngResource'])
|
||||
angular.module("admin.orders", ['admin.indexUtils', 'ngResource', 'mm.foundation'])
|
||||
|
||||
@@ -2,5 +2,5 @@ angular.module("ofn.admin").directive "imageModal", ($modal, ProductImageService
|
||||
restrict: 'C'
|
||||
link: (scope, elem, attrs, ctrl) ->
|
||||
elem.on "click", (ev) =>
|
||||
scope.uploadModal = $modal.open(templateUrl: 'admin/modals/image_upload.html', controller: ctrl, scope: scope, windowClass: 'product-image-upload')
|
||||
scope.uploadModal = $modal.open(templateUrl: 'admin/modals/image_upload.html', controller: ctrl, scope: scope, windowClass: 'simple-modal')
|
||||
ProductImageService.configure(scope.product)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
%h4.modal-title
|
||||
= t('js.admin.orders.index.compiling_invoices')
|
||||
|
||||
%p.message{ ng: { show: 'message' } }
|
||||
{{message}}
|
||||
%p.error{ ng: { show: 'error' } }
|
||||
{{error}}
|
||||
|
||||
%img.spinner{ src: "/assets/spinning-circles.svg", ng: { show: "loading" } }
|
||||
%p{ ng: { show: "loading" } }
|
||||
= t('js.admin.orders.index.please_wait')
|
||||
|
||||
%a.button{ target: '_blank', ng: { click: 'showBulkInvoice()', href: '/admin/orders/invoices/{{invoice_id}}', show: "!loading && !error" } }
|
||||
= t('js.admin.orders.index.view_file')
|
||||
|
||||
@@ -17,4 +17,3 @@
|
||||
@import 'variables';
|
||||
@import 'components/*';
|
||||
@import '*';
|
||||
@import 'pages/*';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@import '../variables';
|
||||
|
||||
.reveal-modal.product-image-upload {
|
||||
.reveal-modal.simple-modal {
|
||||
width: 300px;
|
||||
|
||||
.close-reveal-modal {
|
||||
@@ -103,3 +103,34 @@ table.index td.actions {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
}
|
||||
|
||||
.index-controls {
|
||||
|
||||
button {
|
||||
float: right;
|
||||
|
||||
&:disabled {
|
||||
background-color: $disabled-button;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.simple-modal {
|
||||
text-align: center;
|
||||
|
||||
.modal-title {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
.message, .error {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $warning-red;
|
||||
}
|
||||
}
|
||||
|
||||
31
app/controllers/spree/admin/invoices_controller.rb
Normal file
31
app/controllers/spree/admin/invoices_controller.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
module Spree
|
||||
module Admin
|
||||
class InvoicesController < Spree::Admin::BaseController
|
||||
respond_to :json
|
||||
|
||||
def create
|
||||
invoice_service = BulkInvoiceService.new
|
||||
invoice_service.start_pdf_job(params[:order_ids])
|
||||
|
||||
render json: invoice_service.id, status: :ok
|
||||
end
|
||||
|
||||
def show
|
||||
invoice_id = params[:id]
|
||||
invoice_pdf = BulkInvoiceService.new.filepath(invoice_id)
|
||||
|
||||
send_file(invoice_pdf, type: 'application/pdf', disposition: :inline)
|
||||
end
|
||||
|
||||
def poll
|
||||
invoice_id = params[:invoice_id]
|
||||
|
||||
if BulkInvoiceService.new.invoice_created? invoice_id
|
||||
render json: { created: true }, status: :ok
|
||||
else
|
||||
render json: { created: false }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -37,8 +37,10 @@ Spree::Admin::OrdersController.class_eval do
|
||||
end
|
||||
|
||||
def invoice
|
||||
template = if Spree::Config.invoice_style2? then "spree/admin/orders/invoice2" else "spree/admin/orders/invoice" end
|
||||
pdf = render_to_string pdf: "invoice-#{@order.number}.pdf", template: template, formats: [:html], encoding: "UTF-8"
|
||||
pdf = render_to_string pdf: "invoice-#{@order.number}.pdf",
|
||||
template: invoice_template,
|
||||
formats: [:html], encoding: "UTF-8"
|
||||
|
||||
Spree::OrderMailer.invoice_email(@order.id, pdf).deliver
|
||||
flash[:success] = t('admin.orders.invoice_email_sent')
|
||||
|
||||
@@ -46,8 +48,7 @@ Spree::Admin::OrdersController.class_eval do
|
||||
end
|
||||
|
||||
def print
|
||||
template = if Spree::Config.invoice_style2? then "spree/admin/orders/invoice2" else "spree/admin/orders/invoice" end
|
||||
render pdf: "invoice-#{@order.number}", template: template, encoding: "UTF-8"
|
||||
render pdf: "invoice-#{@order.number}", template: invoice_template, encoding: "UTF-8"
|
||||
end
|
||||
|
||||
def print_ticket
|
||||
@@ -60,6 +61,10 @@ Spree::Admin::OrdersController.class_eval do
|
||||
|
||||
private
|
||||
|
||||
def invoice_template
|
||||
Spree::Config.invoice_style2? ? "spree/admin/orders/invoice2" : "spree/admin/orders/invoice"
|
||||
end
|
||||
|
||||
def require_distributor_abn
|
||||
unless @order.distributor.abn.present?
|
||||
flash[:error] = t(:must_have_valid_business_number, enterprise_name: @order.distributor.name)
|
||||
|
||||
@@ -210,7 +210,9 @@ class AbilityDecorator
|
||||
# during the order creation process from the admin backend
|
||||
order.distributor.nil? || user.enterprises.include?(order.distributor) || order.order_cycle.andand.coordinated_by?(user)
|
||||
end
|
||||
can [:admin, :bulk_management, :managed], Spree::Order if user.admin? || user.enterprises.any?(&:is_distributor)
|
||||
can [:admin, :bulk_management, :managed, :bulk_invoice], Spree::Order do
|
||||
user.admin? || user.enterprises.any?(&:is_distributor)
|
||||
end
|
||||
can [:admin, :visible], Enterprise
|
||||
can [:admin, :index, :create, :update, :destroy], :line_item
|
||||
can [:admin, :index, :create], Spree::LineItem
|
||||
|
||||
56
app/services/bulk_invoice_service.rb
Normal file
56
app/services/bulk_invoice_service.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
class BulkInvoiceService
|
||||
include WickedPdf::PdfHelper
|
||||
attr_reader :id
|
||||
|
||||
def initialize
|
||||
@id = new_invoice_id
|
||||
end
|
||||
|
||||
def start_pdf_job(order_ids)
|
||||
pdf = CombinePDF.new
|
||||
orders = Spree::Order.where(id: order_ids)
|
||||
|
||||
orders.each do |order|
|
||||
invoice = renderer.render_to_string pdf: "invoice-#{order.number}.pdf",
|
||||
template: invoice_template,
|
||||
formats: [:html], encoding: "UTF-8",
|
||||
locals: { :@order => order }
|
||||
|
||||
pdf << CombinePDF.parse(invoice)
|
||||
end
|
||||
|
||||
pdf.save "#{file_directory}/#{@id}.pdf"
|
||||
end
|
||||
handle_asynchronously :start_pdf_job
|
||||
|
||||
def invoice_created?(invoice_id)
|
||||
File.exist? filepath(invoice_id)
|
||||
end
|
||||
|
||||
def filepath(invoice_id)
|
||||
"#{directory}/#{invoice_id}.pdf"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def new_invoice_id
|
||||
Time.zone.now.to_i.to_s
|
||||
end
|
||||
|
||||
def directory
|
||||
'tmp/invoices'
|
||||
end
|
||||
|
||||
def renderer
|
||||
ApplicationController.new
|
||||
end
|
||||
|
||||
def invoice_template
|
||||
Spree::Config.invoice_style2? ? "spree/admin/orders/invoice2" : "spree/admin/orders/invoice"
|
||||
end
|
||||
|
||||
def file_directory
|
||||
Dir.mkdir(directory) unless File.exist?(directory)
|
||||
directory
|
||||
end
|
||||
end
|
||||
@@ -16,14 +16,19 @@
|
||||
- content_for :table_filter do
|
||||
= render partial: 'filters'
|
||||
|
||||
.row
|
||||
.row.index-controls{'ng-show' => '!RequestMonitor.loading && orders.length > 0'}
|
||||
= render partial: 'per_page_controls'
|
||||
|
||||
%button.invoices-modal{'ng-controller' => 'bulkInvoiceCtrl', 'ng-click' => 'createBulkInvoice()', 'ng-disabled' => 'selected_orders.length == 0'}
|
||||
= t('.print_invoices')
|
||||
|
||||
%table#listing_orders.index.responsive{width: "100%", 'ng-init' => 'initialise()', 'ng-show' => "!RequestMonitor.loading && orders.length > 0" }
|
||||
%colgroup
|
||||
%col{style: "width: 10%"}
|
||||
%col{style: "width: 3%"}
|
||||
%thead
|
||||
%tr
|
||||
%th
|
||||
%input{type: 'checkbox', 'ng-click' => 'toggleAll()', 'ng-model' => 'select_all'}
|
||||
%th
|
||||
= t(:products_distributor)
|
||||
%th
|
||||
@@ -39,6 +44,8 @@
|
||||
%th.actions
|
||||
%tbody
|
||||
%tr{ng: {repeat: 'order in orders track by $index', class: {even: "'even'", odd: "'odd'"}}, 'ng-class' => "'state-{{order.state}}'"}
|
||||
%td.align-center
|
||||
%input{type: 'checkbox', 'ng-model' => 'checkboxes[order.id]', 'ng-change' => 'toggleSelection(order.id)'}
|
||||
%td.align-center
|
||||
{{order.distributor_name}}
|
||||
%td.align-center
|
||||
|
||||
@@ -30,13 +30,14 @@
|
||||
|
||||
%td{ :align => "right" }
|
||||
%strong= "#{t('.to')}: #{@order.ship_address.full_name}"
|
||||
- if @order.customer.code.present?
|
||||
- if @order.andand.customer.andand.code.present?
|
||||
%br
|
||||
= "#{t('.code')}: #{@order.customer.code}"
|
||||
%br
|
||||
= @order.ship_address.full_address
|
||||
%br
|
||||
= "#{@order.customer.email},"
|
||||
- if @order.andand.customer.andand.email.present?
|
||||
= "#{@order.customer.email},"
|
||||
= "#{@order.bill_address.phone}"
|
||||
|
||||
= render 'spree/admin/orders/invoice_table'
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
= t :invoice_billing_address
|
||||
%br
|
||||
%strong= @order.ship_address.full_name
|
||||
- if @order.customer.code.present?
|
||||
- if @order.andand.customer.andand.code.present?
|
||||
%br
|
||||
= "Code: #{@order.customer.code}"
|
||||
%br
|
||||
|
||||
@@ -2593,6 +2593,11 @@ See the %{link} to find out more about %{sitename}'s features and to start using
|
||||
orders:
|
||||
index:
|
||||
per_page: "%{results} per page"
|
||||
view_file: View File
|
||||
compiling_invoices: Compiling Invoices
|
||||
bulk_invoice_created: Bulk Invoice created
|
||||
bulk_invoice_failed: Failed to create Bulk Invoice
|
||||
please_wait: Please wait until the PDF is ready before closing this modal.
|
||||
resend_user_email_confirmation:
|
||||
resend: "Resend"
|
||||
sending: "Resend..."
|
||||
@@ -2698,6 +2703,7 @@ See the %{link} to find out more about %{sitename}'s features and to start using
|
||||
no_orders_found: "No Orders Found"
|
||||
results_found: "%{number} Results found."
|
||||
viewing: "Viewing %{start} to %{end}."
|
||||
print_invoices: "Print Invoices"
|
||||
invoice:
|
||||
issued_on: Issued on
|
||||
tax_invoice: TAX INVOICE
|
||||
|
||||
@@ -74,6 +74,12 @@ Spree::Core::Engine.routes.prepend do
|
||||
get :print, on: :member
|
||||
get :print_ticket, on: :member
|
||||
get :managed, on: :collection
|
||||
|
||||
collection do
|
||||
resources :invoices, only: [:create, :show] do
|
||||
get :poll
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
43
spec/controllers/spree/admin/invoices_controller_spec.rb
Normal file
43
spec/controllers/spree/admin/invoices_controller_spec.rb
Normal file
@@ -0,0 +1,43 @@
|
||||
require 'spec_helper'
|
||||
|
||||
describe Spree::Admin::InvoicesController, type: :controller do
|
||||
let(:order) { create(:order_with_totals_and_distribution) }
|
||||
let(:user) { create(:admin_user) }
|
||||
|
||||
before do
|
||||
allow(controller).to receive(:spree_current_user) { user }
|
||||
end
|
||||
|
||||
describe "#create" do
|
||||
it "enqueues a job to create a bulk invoice and returns the filename" do
|
||||
expect do
|
||||
spree_post :create, order_ids: [order.id]
|
||||
end.to enqueue_job Delayed::PerformableMethod
|
||||
|
||||
expect(Delayed::Job.last.payload_object.method_name).to eq :start_pdf_job_without_delay
|
||||
end
|
||||
end
|
||||
|
||||
describe "#poll" do
|
||||
let(:invoice_id) { '479186263' }
|
||||
|
||||
context "when the file is available" do
|
||||
it "returns true" do
|
||||
allow(File).to receive(:exist?).and_return(true)
|
||||
spree_get :poll, invoice_id: invoice_id
|
||||
|
||||
expect(response.body).to eq({ created: true }.to_json)
|
||||
expect(response.status).to eq 200
|
||||
end
|
||||
end
|
||||
|
||||
context "when the file is not available" do
|
||||
it "returns false" do
|
||||
spree_get :poll, invoice_id: invoice_id
|
||||
|
||||
expect(response.body).to eq({ created: false }.to_json)
|
||||
expect(response.status).to eq 422
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -751,9 +751,9 @@ feature %q{
|
||||
end
|
||||
|
||||
# Shows upload modal
|
||||
expect(page).to have_selector "div.reveal-modal.product-image-upload"
|
||||
expect(page).to have_selector "div.reveal-modal"
|
||||
|
||||
within "div.reveal-modal.product-image-upload" do
|
||||
within "div.reveal-modal" do
|
||||
# Shows preview of current image
|
||||
expect(page).to have_css "img.preview"
|
||||
|
||||
@@ -765,7 +765,7 @@ feature %q{
|
||||
expect(page).to have_no_css "img.spinner", visible: true
|
||||
end
|
||||
|
||||
expect(page).to have_no_selector "div.reveal-modal.product-image-upload"
|
||||
expect(page).to have_no_selector "div.reveal-modal"
|
||||
|
||||
within "table#listing_products tr#p_#{product.id}" do
|
||||
# New thumbnail is shown in image column
|
||||
@@ -775,7 +775,7 @@ feature %q{
|
||||
page.find("a.image-modal").click
|
||||
end
|
||||
|
||||
expect(page).to have_selector "div.reveal-modal.product-image-upload"
|
||||
expect(page).to have_selector "div.reveal-modal"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
40
spec/services/bulk_invoice_service_spec.rb
Normal file
40
spec/services/bulk_invoice_service_spec.rb
Normal file
@@ -0,0 +1,40 @@
|
||||
require 'spec_helper'
|
||||
|
||||
describe BulkInvoiceService do
|
||||
let(:service) { BulkInvoiceService.new }
|
||||
|
||||
describe "#start_pdf_job" do
|
||||
it "starts a background process to create a pdf with multiple invoices" do
|
||||
expect do
|
||||
service.start_pdf_job [1, 2]
|
||||
end.to enqueue_job Delayed::PerformableMethod
|
||||
|
||||
expect(Delayed::Job.last.payload_object.method_name).to eq :start_pdf_job_without_delay
|
||||
end
|
||||
end
|
||||
|
||||
describe "#invoice_created?" do
|
||||
context "when the invoice has been created" do
|
||||
it "returns true" do
|
||||
allow(File).to receive(:exist?).and_return(true)
|
||||
|
||||
created = service.invoice_created? '45891723'
|
||||
expect(created).to be_truthy
|
||||
end
|
||||
end
|
||||
|
||||
context "when the invoice has not been created" do
|
||||
it "returns false" do
|
||||
created = service.invoice_created? '1234567'
|
||||
expect(created).to_not be_truthy
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#filepath" do
|
||||
it "returns the filepath of a given invoice" do
|
||||
filepath = service.filepath '1234567'
|
||||
expect(filepath).to eq 'tmp/invoices/1234567.pdf'
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user