move automatic invoices to plugin

changes on deposit calculation

tiny changes on group order invoice pdf
This commit is contained in:
viehlieb 2023-10-10 23:11:34 +02:00
parent 42a1773a87
commit e78d1ad072
67 changed files with 5579 additions and 69 deletions

View file

@ -1,4 +1,4 @@
FROM ruby:2.7
FROM ruby:2.7.8
# Install dependencies
RUN deps='libmagic-dev chromium nodejs' && \
@ -19,6 +19,7 @@ ENV PORT=3000 \
WORKDIR /app
RUN gem update --system
RUN gem install bundler
RUN bundle config build.nokogiri "--use-system-libraries"

View file

@ -71,6 +71,7 @@ gem 'foodsoft_links', path: 'plugins/links'
gem 'foodsoft_messages', path: 'plugins/messages'
gem 'foodsoft_polls', path: 'plugins/polls'
gem 'foodsoft_wiki', path: 'plugins/wiki'
gem 'foodsoft_automatic_invoices', path: 'plugins/automatic_invoices'
# plugins not enabled by default
# gem 'foodsoft_current_orders', path: 'plugins/current_orders'

View file

@ -16,6 +16,13 @@ GIT
acts_as_versioned (0.6.0)
activerecord (>= 3.0.9)
PATH
remote: plugins/automatic_invoices
specs:
foodsoft_automatic_invoices (0.0.1)
deface (~> 1.9)
rails
PATH
remote: plugins/discourse
specs:
@ -630,6 +637,7 @@ DEPENDENCIES
exception_notification
factory_bot_rails
faker
foodsoft_automatic_invoices!
foodsoft_discourse!
foodsoft_documents!
foodsoft_links!

View file

@ -8,6 +8,7 @@ class Finance::BalancingController < Finance::BaseController
flash.now.alert = t('.alert') if @order.closed?
@comments = @order.comments
@articles = @order.order_articles.ordered_or_member.includes(:article, :article_price,
group_order_articles: { group_order: :ordergroup })
@ -24,7 +25,6 @@ class Finance::BalancingController < Finance::BaseController
else
@articles
end
render layout: false if request.xhr?
end

View file

@ -0,0 +1 @@
= render partial: 'finance/balancing/edit_results_by_articles', locals: {order: @order, articles: @articles, comments: @comments }

View file

@ -36,28 +36,28 @@
%th= heading_helper Article, :tax
%th= heading_helper Article, :deposit
%th{:colspan => "2"}
- unless @order.closed?
- unless order.closed?
.btn-group
= link_to t('.add_article'), new_order_order_article_path(@order), remote: true,
= link_to t('.add_article'), new_order_order_article_path(order), remote: true,
class: 'btn btn-small'
= link_to '#', data: {toggle: 'dropdown'}, class: 'btn btn-small dropdown-toggle' do
%span.caret
%ul.dropdown-menu
%li= link_to t('.add_article'), new_order_order_article_path(@order), remote: true
%li= link_to t('.edit_transport'), edit_transport_finance_order_path(@order), remote: true
%li= link_to t('.add_article'), new_order_order_article_path(order), remote: true
%li= link_to t('.edit_transport'), edit_transport_finance_order_path(order), remote: true
%tbody.list#result_table
- for order_article in @articles.select { |oa| oa.units > 0 }
- for order_article in articles.select { |oa| oa.units > 0 }
= render :partial => "order_article_result", :locals => {:order_article => order_article}
%tr
%td{ colspan: 10 } The following were not ordered
- for order_article in @articles.select { |oa| oa.units == 0 }
- for order_article in articles.select { |oa| oa.units == 0 }
= render :partial => "order_article_result", :locals => {:order_article => order_article}
- if @order.transport
- if order.transport
%tr
%td{ colspan: 5 }= heading_helper Order, :transport
%td{ colspan: 3, data: {value: @order.transport} }= number_to_currency(@order.transport)
%td= link_to t('ui.edit'), edit_transport_finance_order_path(@order), remote: true,
%td{ colspan: 3, data: {value: order.transport} }= number_to_currency(order.transport)
%td= link_to t('ui.edit'), edit_transport_finance_order_path(order), remote: true,
class: 'btn btn-mini' unless order_article.order.closed?

View file

@ -1,6 +1,7 @@
%td.closed.name
= link_to order_article.article.name, '#', 'data-toggle-this' => "#group_order_articles_#{order_article.id}"
%td= order_article.article.order_number
-# :plain => true destroys deface functionality
%td{title: units_history_line(order_article, :plain => true)}
= order_article.units
= pkg_helper order_article.article_price
@ -22,6 +23,6 @@
%td
= link_to t('ui.edit'), edit_order_order_article_path(order_article.order, order_article), remote: true,
class: 'btn btn-mini' unless order_article.order.closed?
%td
%td.end
= link_to t('ui.delete'), order_order_article_path(order_article.order, order_article), method: :delete,
remote: true, data: {confirm: t('.confirm')}, class: 'btn btn-danger btn-mini' unless order_article.order.closed?

View file

@ -6,7 +6,7 @@
%tr
%td= t('.net_amount')
%td.numeric= number_to_currency(order.sum(:net))
%tr
%tr.gross-amount
%td= t('.gross_amount')
%td.numeric= number_to_currency(order.sum(:gross))
%tr

View file

@ -77,5 +77,6 @@
remote: true
%section#results
= render 'edit_results_by_articles'
= render partial: 'article_results', locals: { order: @order, articles: @articles, comments: @comments }
%p= link_to_top

View file

@ -1 +1 @@
= render 'form'
= render 'form'

View file

@ -35,9 +35,8 @@
%td= "#{order_article.quantity} + #{order_article.tolerance}"
- else
%td= "#{order_article.quantity}"
%td{title: units_history_line(order_article, plain: true)}
= units
= pkg_helper order_article.price
= render "units_history", order_article: order_article, units: units
%p
= t '.prices_sum'
= "#{number_to_currency(total_net)} / #{number_to_currency(total_gross)}"

View file

@ -0,0 +1,3 @@
%td{title: units_history_line(order_article, plain: true)}
= units
= pkg_helper order_article.price

View file

@ -1,46 +0,0 @@
# Foodsoft database configuration for MySQL
#
# This file is in the public domain
#
#
# MySQL versions 4.1 and 5.0 are recommended.
#
# Install the MYSQL driver
# gem install mysql2
#
# Ensure the MySQL gem is defined in your Gemfile
# gem 'mysql2'
#
# And be sure to use new-style password hashing:
# http://dev.mysql.com/doc/refman/5.0/en/old-client.html
development:
adapter: mysql2
encoding: utf8mb4
reconnect: false
database: foodsoft_development
pool: 5
host: localhost
# socket: /tmp/mysql.sock
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
adapter: mysql2
encoding: utf8mb4
reconnect: false
database: foodsoft_test
pool: 5
host: localhost
# socket: /tmp/mysql.sock
production:
adapter: mysql2
encoding: utf8mb4
reconnect: false
pool: 5
host: <%= ENV['FOODSOFT_DB_HOST'] %>
database: <%= ENV['FOODSOFT_DB_NAME'] %>
username: <%= ENV['FOODSOFT_DB_USER'] %>
password: <%= ENV['FOODSOFT_DB_PASSWORD'] %>
# socket: /tmp/mysql.sock

View file

@ -860,7 +860,8 @@ de:
summary:
changed: Daten wurden verändert!
duration: von %{starts} bis %{ends}
fc_amount: 'FC-Betrag:'
fc_amount: 'FC-Gesamtbetrag:'
fc_amount_without_deposit: 'FC-Betrag (ohne Pfand):'
fc_profit: FC Gewinn
gross_amount: 'Bruttobetrag:'
groups_amount: 'Gruppenbeträge:'
@ -1554,6 +1555,7 @@ de:
starts: läuft von %{starts}
starts_ends: läuft von %{starts} bis %{ends}
description2: "%{ordergroups} haben %{article_count} Artikel mit einem Gesamtwert von %{net_sum} / %{gross_sum} (netto / brutto) bestellt."
description3: " Zuzüglich Pfand %{net_deposit} / %{deposit} (netto / brutto)."
group_orders: 'Gruppenbestellungen:'
search_placeholder:
articles: Suche nach Artikeln ...

View file

@ -11,7 +11,6 @@ services:
build:
context: .
dockerfile: Dockerfile-dev
platform: linux/x86_64
command: ./proc-start worker
volumes:
- bundle:/usr/local/bundle

View file

@ -0,0 +1,42 @@
FoodsoftAutomaticInvoices
=====================
Foodsoft is currently designed to work with one order at a time. In practice,
however there can be multiple orders open at the same time, with one pickup
day. The proper solution to this is to introduce the notion of order cycles,
with each order belonging to a cycle. Until that time, we have this plugin,
with screens for working on all orders that are closed-but-not-finished.
Important: be sure to settle orders from the previous order cycle, before
you close any. If you don't, articles from previous and current dates start
to mix up (if you do, settle the old ones asap).
* `current_orders/orders/receive` for a list of orders that can be received.
* `current_orders/orders.pdf?document=(groups|articles)` for PDFs for all
orders that are closed but not settled.
* `current_orders/articles` to edit an order article's ordergroups in all
orders that are closed but not settled.
* `current_orders/ordergroups` to edit an ordergroup's order articles in all
orders that are closed but not settled.
* `current_orders/group_orders` for all articles in the user's group orders
from orders that are not settled. Can be used as a "shopping-cart overview"
or "checkout" page.
New menu items will be added in the "Orders" menu. Please note that members
with _Orders_ permission will now be able to edit the amounts members received
in some of these screens, something that was previously restricted to the
_Finance_ permission.
This plugin is not enabled by default. To install it, add uncomment the
corresponding line in the `Gemfile`, or add:
```Gemfile
gem 'foodsoft_current_orders', path: 'plugins/current_orders'
```
This plugin introduces the foodcoop config option `use_current_orders`, which
needs to be set to `true` to enable the plugin. This can be done in the
configuration screen or `config/app_config.yml`.
This plugin is part of the foodsoft package and uses the AGPL-3 license (see
foodsoft's LICENSE for the full license text).

View file

@ -0,0 +1,70 @@
#order-footer-override, .article-info {
text-align: left;
z-index: 1;
position: fixed;
bottom: 0;
background-color: #E4EED6;
border-top: 2px solid #78B74E;
#total-sum {
width: 22em;
margin: .5em 2em 0 0;
float: right;
#order-button {
margin: .5em 0;
input:disabled {
background-color: red; }
}
}
}
/* Hide the orders article info for small screens
to prevent the "save order" button to disappear */
@media only screen and (max-width: 950px) {
tr.order-article:hover .article-info {
display: none;
}
tr.order-article:focus .article-info {
display: none;
}
}
#order-footer-override {
width: 100%;
right: 0;
left: 0;
}
.article-info {
z-index: 2;
width: 40em;
height: 8em;
border: none;
left: 30px;
.article-name {
text-align: center;
margin: 2px 0;
margin-bottom: 5px;
width: 100%;
font-weight: bold;
}
.pull-right {
width: 35%;
}
.pull-left {
width: 60%;
}
}
tr.order-article .article-info {
display: none;
}
tr.order-article:focus{
background-color: #E4EED6;
}
tr.order-article:focus .article-info {
display: block;
}

View file

@ -0,0 +1,85 @@
class GroupOrderInvoicesController < ApplicationController
include Concerns::SendOrderPdf
before_action :authenticate_finance
def show
@group_order_invoice = GroupOrderInvoice.find(params[:id])
raise RecordInvalid unless FoodsoftConfig[:contact][:tax_number]
respond_to do |format|
format.pdf do
send_group_order_invoice_pdf @group_order_invoice if FoodsoftConfig[:contact][:tax_number]
end
end
rescue ActiveRecord::RecordInvalid => e
redirect_back fallback_location: root_path, notice: 'Something went wrong', alert: I18n.t('errors.general_msg', msg: "#{e} " + I18n.t('errors.check_tax_number'))
end
def create
go = GroupOrder.find(params[:group_order])
@order = go.order
GroupOrderInvoice.find_or_create_by!(group_order_id: go.id)
respond_to do |format|
format.js
end
redirect_back fallback_location: root_path
rescue StandardError => e
redirect_back fallback_location: root_path, notice: 'Something went wrong', :alert => I18n.t('errors.general_msg', :msg => e)
end
def destroy
goi = GroupOrderInvoice.find(params[:id])
@order = goi.group_order.order
goi.destroy
respond_to do |format|
format.js
format.json { head :no_content }
end
end
def create_multiple
invoice_date = params[:group_order_invoice][:invoice_date]
order_id = params[:group_order_invoice][:order_id]
@order = Order.find(order_id)
gos = GroupOrder.where("order_id = ?", order_id)
gos.each do |go|
goi = GroupOrderInvoice.find_or_create_by!(group_order_id: go.id)
goi.invoice_date = invoice_date
goi.invoice_number = goi.generate_invoice_number(1)
goi.save!
end
respond_to do |format|
format.js
end
end
def download_all
order = Order.find(params[:order_id])
invoices = order.group_orders.map(&:group_order_invoice)
pdf = {}
file_paths = []
temp_file = Tempfile.new("all_invoices_for_order_#{order.id}.zip")
Zip::File.open(temp_file.path, Zip::File::CREATE) do |zipfile|
invoices.each do |invoice|
pdf = create_invoice_pdf(invoice)
file_path = File.join("tmp", pdf.filename)
File.open(file_path, 'w:ASCII-8BIT') do |file|
file.write(pdf.to_pdf)
end
file_paths << file_path
zipfile.add(pdf.filename, file_path) unless zipfile.find_entry(pdf.filename)
end
end
zip_data = File.read(temp_file.path)
file_paths.each do |file_path|
File.delete(file_path)
end
respond_to do |format|
format.html do
send_data(zip_data, type: 'application/zip', filename: "#{l order.ends, format: :file}-#{order.supplier.name}-#{order.id}.zip", disposition: 'attachment')
end
end
end
end

View file

@ -0,0 +1,301 @@
class GroupOrderInvoicePdf < RenderPdf
def filename
ordergroup_name = @options[:ordergroup].name || "OrderGroup"
"#{ordergroup_name}_" + I18n.t('documents.group_order_invoice_pdf.filename', :number => @options[:invoice_number]) + '.pdf'
end
def title
I18n.t('documents.group_order_invoice_pdf.title', :supplier => @options[:supplier])
end
def body
contact = FoodsoftConfig[:contact].symbolize_keys
ordergroup = @options[:ordergroup]
# From paragraph
bounding_box [margin_box.right - 200, margin_box.top - 20], width: 200 do
text I18n.t('documents.group_order_invoice_pdf.invoicer')
move_down 7
text FoodsoftConfig[:name], size: fontsize(9), align: :left
move_down 5
text contact[:street], size: fontsize(9), align: :left
move_down 5
text "#{contact[:zip_code]} #{contact[:city]}", size: fontsize(9), align: :left
move_down 5
if contact[:phone].present?
text "#{Supplier.human_attribute_name :phone}: #{contact[:phone]}", size: fontsize(9), align: :left
move_down 5
end
text "#{Supplier.human_attribute_name :email}: #{contact[:email]}", size: fontsize(9), align: :left if contact[:email].present?
move_down 5
text I18n.t('documents.group_order_invoice_pdf.tax_number', :number => @options[:tax_number]), size: fontsize(9), align: :left
end
# Receiving Ordergroup
bounding_box [margin_box.left, margin_box.top - 20], width: 200 do
text I18n.t('documents.group_order_invoice_pdf.invoicee')
move_down 7
text I18n.t('documents.group_order_invoice_pdf.ordergroup.name', ordergroup: ordergroup.name.to_s), size: fontsize(9)
move_down 5
if ordergroup.contact_address.present?
text I18n.t('documents.group_order_invoice_pdf.ordergroup.contact_address', contact_address: ordergroup.contact_address.to_s), size: fontsize(9)
move_down 5
end
if ordergroup.contact_phone.present?
text I18n.t('documents.group_order_invoice_pdf.ordergroup.contact_phone', contact_phone: ordergroup.contact_phone.to_s), size: fontsize(9)
move_down 5
end
if ordergroup.customer_number.present?
text I18n.t('documents.group_order_invoice_pdf.ordergroup.customer_number', customer_number: ordergroup.customer_number.to_s), size: fontsize(9)
move_down 5
end
end
# invoice Date and nnvoice number
bounding_box [margin_box.right - 200, margin_box.top - 150], width: 200 do
text I18n.t('documents.group_order_invoice_pdf.invoice_date', invoice_date: @options[:invoice_date].strftime(I18n.t('date.formats.default'))), align: :left
move_down 5
text I18n.t('documents.group_order_invoice_pdf.invoice_number', invoice_number: @options[:invoice_number]), align: :left
end
move_down 15
# kind of the "body" of the invoice
text I18n.t('documents.group_order_invoice_pdf.payment_method', payment_method: @options[:payment_method])
move_down 15
text I18n.t('documents.group_order_invoice_pdf.table_headline')
move_down 5
#------------- Table Data -----------------------
@group_order = GroupOrder.find(@options[:group_order].id)
if FoodsoftConfig[:group_order_invoices][:vat_exempt]
body_for_vat_exempt
else
body_with_vat
end
end
def body_for_vat_exempt
total_gross = 0
data = [I18n.t('documents.group_order_invoice_pdf.vat_exempt_rows')]
move_down 10
group_order_articles = GroupOrderArticle.where(group_order_id: @group_order.id)
separate_deposits = FoodsoftConfig[:group_order_invoices]&.[](:separate_deposits)
group_order_articles.each do |goa|
# if no unit is received, nothing is to be charged
next if goa.result.to_i == 0
goa_total_price = separate_deposits ? goa.total_price_without_deposit : goa.total_price
data << [goa.order_article.article.name,
goa.result.to_i,
number_to_currency(goa.order_article.price.fc_price_without_deposit),
number_to_currency(goa_total_price)]
total_gross += goa_total_price
next unless separate_deposits && goa.order_article.price.deposit > 0.0
goa_total_deposit = goa.result * goa.order_article.price.fc_deposit_price
data << ["zzgl. Pfand",
goa.result.to_i,
number_to_currency(goa.order_article.article.fc_deposit_price),
number_to_currency(goa_total_deposit)]
total_gross += goa_total_deposit
end
table data, cell_style: { size: fontsize(8), overflow: :shrink_to_fit } do |table|
table.header = true
table.position = :center
table.cells.border_width = 1
table.cells.border_color = '666666'
table.row(0).column(0..4).width = 80
table.row(0).border_bottom_width = 2
table.columns(1).align = :right
table.columns(1..6).align = :right
end
move_down 5
sum = []
sum << [nil, nil, I18n.t('documents.group_order_invoice_pdf.sum_to_pay_gross'), number_to_currency(total_gross)]
# table for sum
table sum, cell_style: { size: fontsize(8), overflow: :shrink_to_fit } do |table|
table.header = true
table.position = :center
table.cells.border_width = 1
table.cells.border_color = '666666'
table.row(0).columns(2..4).style(align: :bottom)
table.row(0).border_bottom_width = 2
table.row(0..-1).columns(0..1).border_width = 0
table.rows(0..-1).columns(0..4).width = 80
table.row(0).column(-1).style(font_style: :bold)
table.row(0).column(-2).style(font_style: :bold)
table.row(0).column(-1).size = fontsize(10)
table.row(0).column(-2).size = fontsize(10)
table.columns(1).align = :right
table.columns(1..6).align = :right
end
move_down 25
text I18n.t('documents.group_order_invoice_pdf.small_business_regulation')
move_down 10
end
def body_with_vat
separate_deposits = FoodsoftConfig[:group_order_invoices]&.[](:separate_deposits)
total_gross = 0
total_net = 0
# Articles
tax_hash_net = Hash.new(0) # for summing up article net prices grouped into vat percentage
tax_hash_gross = Hash.new(0) # same here with gross prices
tax_hash_fc = Hash.new(0)
tax_hash_deposit_total = Hash.new(0) # for summing up deposit prices grouped into vat percentage
if separate_deposits
total_deposit = 0
total_deposit_fc = 0
total_deposit_gross = 0
tax_hash_deposit_net = Hash.new(0) # same here with gross prices
tax_hash_deposit_gross = Hash.new(0) # for summing up deposit gross prices grouped into vat percentage
tax_hash_deposit_fc = Hash.new(0)
end
marge = FoodsoftConfig[:price_markup]
# data table looks different when price_markup > 0
data = if marge == 0
[I18n.t('documents.group_order_invoice_pdf.no_price_markup_rows')]
else
[I18n.t('documents.group_order_invoice_pdf.price_markup_rows', marge: marge)]
end
goa_tax_hash = GroupOrderArticle.where(group_order_id: @group_order.id).find_each.group_by { |oat| oat.order_article.price.tax }
goa_tax_hash.each do |tax, group_order_articles|
group_order_articles.each do |goa|
# if no unit is received, nothing is to be charged
next if goa.result.to_i == 0
order_article = goa.order_article
goa_total_net = goa.result * order_article.price.price
goa_total_fc = separate_deposits ? goa.total_price_without_deposit : goa.total_price
goa_total = goa.total_price
data << [order_article.article.name,
goa.result.to_i,
number_to_currency(order_article.price.price),
number_to_currency(goa_total_net),
tax.to_s + '%',
number_to_currency(goa_total_fc)]
if separate_deposits && order_article.price.deposit > 0.0
goa_deposit = goa.result * order_article.price.net_deposit_price
goa_total_deposit = goa.result * order_article.price.fc_deposit_price
data << ["zzgl. Pfand",
goa.result.to_i,
number_to_currency(order_article.price.net_deposit_price),
number_to_currency(goa_deposit),
tax.to_s + '%',
number_to_currency(goa_total_deposit)]
total_deposit += goa_deposit
total_deposit_fc += goa_total_deposit
total_deposit_gross += goa.result * order_article.price.gross_price
tax_hash_deposit_net[tax.to_i] += goa_deposit
tax_hash_deposit_gross[tax.to_i] += goa.result * order_article.price.deposit
tax_hash_deposit_total[tax.to_i] += goa_total_deposit
end
tax_hash_net[tax.to_i] += goa_total_net
tax_hash_fc[tax.to_i] += goa_total
tax_hash_gross[tax.to_i] += goa.result * order_article.price.gross_price
total_net += goa_total_net
total_gross += goa_total_fc
end
end
# Two separate tables for sum and individual data
# article information + data
table data, cell_style: { size: fontsize(8), overflow: :shrink_to_fit } do |table|
table.header = true
table.position = :center
table.cells.border_width = 1
table.cells.border_color = '666666'
table.row(0).columns(0..6).style(background_color: 'cccccc', font_style: :bold)
table.rows(0..-1).columns(0..6).width = 80
table.row(0).border_bottom_width = 2
table.columns(1).align = :right
table.columns(1..6).align = :right
end
sum = if marge > 0
[[nil, nil, "Netto", "MwSt", "FC marge", "Brutto"]]
else
[[nil, nil, nil, "Netto", "MwSt", "Brutto"]]
end
tax_hash_net.each_key do |key|
if tax_hash_gross[key] > 0
tmp_sum_array = [nil, "Produkte mit #{key}%", number_to_currency(tax_hash_net[key]), number_to_currency(tax_hash_gross[key] - tax_hash_net[key])]
if marge > 0
tmp_sum_array << number_to_currency(tax_hash_fc[key] - tax_hash_gross[key])
else
tmp_sum_array.unshift(nil)
end
tmp_sum_array << number_to_currency(tax_hash_fc[key])
sum << tmp_sum_array
end
if separate_deposits && (tax_hash_deposit_total[key] > 0)
tmp_sum_array = [nil, "Pfand mit #{key}%", number_to_currency(tax_hash_deposit_net[key]), number_to_currency(tax_hash_deposit_gross[key] - tax_hash_deposit_net[key])]
if marge > 0
tmp_sum_array << number_to_currency(tax_hash_deposit_total[key] - tax_hash_deposit_gross[key])
else
tmp_sum_array.unshift(nil)
end
tmp_sum_array << number_to_currency(tax_hash_deposit_total[key])
sum << tmp_sum_array
end
end
total_deposit_fc ||= 0
tmp_total_sum = [nil, nil, nil, nil]
tmp_total_sum << I18n.t('documents.group_order_invoice_pdf.sum_to_pay_gross')
tmp_total_sum << number_to_currency(total_gross + total_deposit_fc)
sum << tmp_total_sum
move_down 10
table sum, cell_style: { size: fontsize(8), overflow: :shrink_to_fit } do |table|
table.header = true
table.position = :center
table.cells.border_width = 1
table.cells.border_color = '666666'
table.row(0).columns(2..7).style(align: :bottom)
table.row(0).border_bottom_width = 2
table.row(0..-1).column(0).border_width = 0
table.rows(0..-1).columns(0..7).width = 80
table.row(-1).column(-1).style(font_style: :bold)
table.row(-1).column(-2).style(font_style: :bold)
table.row(-1).column(-1).size = fontsize(10)
table.row(-1).column(-2).size = fontsize(10)
table.columns(1).align = :right
table.columns(1..7).align = :right
end
if FoodsoftConfig[:group_order_invoices][:vat_exempt]
move_down 15
text I18n.t('documents.group_order_invoice_pdf.small_business_regulation')
end
move_down 10
end
end

View file

@ -0,0 +1,10 @@
class NotifyGroupOrderInvoiceJob < ApplicationJob
def perform(group_order_invoice)
ordergroup = group_order_invoice.group_order.ordergroup
ordergroup.users.each do |user|
Mailer.deliver_now_with_user_locale user do
Mailer.group_order_invoice(group_order_invoice, user)
end
end
end
end

View file

@ -0,0 +1,170 @@
require 'prawn/measurement_extensions'
class RotatedCell < Prawn::Table::Cell::Text
def initialize(pdf, text, options = {})
options[:content] = text
options[:valign] = :center
options[:align] = :center
options[:rotate_around] = :center
@rotation = -options[:rotate] || 0
super(pdf, [0, pdf.cursor], options)
end
def tan_rotation
Math.tan(Math::PI * @rotation / 180)
end
def skew
(height + (border_top_width / 2.0) + (border_bottom_width / 2.0)) / tan_rotation
end
def styled_width_of(_text)
options = @text_options.reject { |k| k == :style }
with_font { (@pdf.height_of(@content, options) + padding_top + padding_bottom) / tan_rotation }
end
def natural_content_height
options = @text_options.reject { |k| k == :style }
with_font { (@pdf.width_of(@content, options) + padding_top + padding_bottom) * tan_rotation }
end
def draw_borders(point)
@pdf.mask(:line_width, :stroke_color) do
x, y = point
from = [[x - skew, y + (border_top_width / 2.0)],
to = [x, y - height - (border_bottom_width / 2.0)]]
@pdf.line_width = @border_widths[3]
@pdf.stroke_color = @border_colors[3]
@pdf.stroke_line(from, to)
@pdf.undash
end
end
def draw_content
with_font do
with_text_color do
text_box(width: spanned_content_width + FPTolerance + skew,
height: spanned_content_height + FPTolerance,
at: [1 - skew, @pdf.cursor]).render
end
end
end
end
class RenderPdf < Prawn::Document
include ActionView::Helpers::NumberHelper
include ApplicationHelper
TOP_MARGIN = 36
BOTTOM_MARGIN = 23
HEADER_SPACE = 9
FOOTER_SPACE = 3
HEADER_FONT_SIZE = 16
FOOTER_FONT_SIZE = 8
DEFAULT_FONT = 'OpenSans'
def initialize(options = {})
options[:font_size] ||= FoodsoftConfig[:pdf_font_size].try(:to_f) || 12
options[:page_size] ||= FoodsoftConfig[:pdf_page_size] || 'A4'
options[:skip_page_creation] = true
@options = options
@first_page = true
no_footer = @options&.[](:no_footer) ? true : false
super(options)
# Use ttf for better utf-8 compability
font_families.update(
'OpenSans' => {
bold: font_path('OpenSans-Bold.ttf'),
italic: font_path('OpenSans-Italic.ttf'),
bold_italic: font_path('OpenSans-BoldItalic.ttf'),
normal: font_path('OpenSans-Regular.ttf')
}
)
header = options[:title] || title
footer = I18n.l(Time.now, format: :long) unless no_footer
header_size = 0
header_size = height_of(header, size: HEADER_FONT_SIZE, font: DEFAULT_FONT) + HEADER_SPACE if header
footer_size = no_footer ? 0 : height_of(footer, size: FOOTER_FONT_SIZE, font: DEFAULT_FONT) + FOOTER_SPACE
start_new_page(top_margin: TOP_MARGIN + header_size, bottom_margin: BOTTOM_MARGIN + footer_size)
font DEFAULT_FONT
repeat :all, dynamic: true do
bounding_box [bounds.left, bounds.top + header_size], width: bounds.width, height: header_size do
text header, size: HEADER_FONT_SIZE, align: :center, overflow: :shrink_to_fit if header
end
unless no_footer
font_size FOOTER_FONT_SIZE do
bounding_box [bounds.left, bounds.bottom - FOOTER_SPACE], width: bounds.width, height: footer_size do
text footer, align: :left, valign: :bottom
end
bounding_box [bounds.left, bounds.bottom - FOOTER_SPACE], width: bounds.width, height: footer_size do
text I18n.t('lib.render_pdf.page', number: page_number, count: page_count), align: :right, valign: :bottom
end
end
end
end
end
def title
nil
end
def to_pdf
body # Add content, which is defined in subclasses
render # Render pdf
end
# @todo avoid underscore instead of unicode whitespace in pdf :/
def number_to_currency(number, options = {})
super(number, options).gsub("\u202f", ' ') if number
end
def font_size(points = nil, &block)
points *= @options[:font_size] / 12 if points
super(points, &block)
end
# add pagebreak or vertical whitespace, depending on configuration
def down_or_page(space = 10)
if @first_page
@first_page = false
return
end
if pdf_add_page_breaks?
start_new_page
else
move_down space
end
end
protected
def fontsize(size)
size
end
# return whether pagebreak or vertical whitespace is used for breaks
def pdf_add_page_breaks?(docid = nil)
docid ||= self.class.name.underscore
cfg = FoodsoftConfig[:pdf_add_page_breaks]
case cfg
when Array
cfg.index(docid.to_s).any?
when Hash
cfg[docid.to_s]
else
cfg
end
end
def font_path(name)
Rails.root.join('vendor', 'assets', 'fonts', name)
end
end

View file

@ -0,0 +1,58 @@
class GroupOrderInvoice < ApplicationRecord
belongs_to :group_order
validates_presence_of :group_order
validates_uniqueness_of :invoice_number
validate :tax_number_set
after_initialize :init, unless: :persisted?
def generate_invoice_number(count)
trailing_number = count.to_s.rjust(4, '0')
if GroupOrderInvoice.find_by(invoice_number: self.invoice_date.strftime("%Y%m%d") + trailing_number)
generate_invoice_number(count.to_i + 1)
else
self.invoice_date.strftime("%Y%m%d") + trailing_number
end
end
def tax_number_set
if FoodsoftConfig[:contact][:tax_number].blank?
errors.add(:group_order_invoice, "Keine Steuernummer in FoodsoftConfig :contact gesetzt")
end
end
def init
self.invoice_date = Time.now unless invoice_date
self.invoice_number = generate_invoice_number(1) unless self.invoice_number
self.payment_method = FoodsoftConfig[:group_order_invoices]&.[](:payment_method) || I18n.t('activerecord.attributes.group_order_invoice.payment_method') unless self.payment_method
end
def name
I18n.t('activerecord.attributes.group_order_invoice.name') + "_#{invoice_number}"
end
def load_data_for_invoice
invoice_data = {}
order = group_order.order
invoice_data[:supplier] = order.supplier.name
invoice_data[:ordergroup] = group_order.ordergroup
invoice_data[:group_order] = group_order
invoice_data[:invoice_number] = invoice_number
invoice_data[:invoice_date] = invoice_date
invoice_data[:tax_number] = FoodsoftConfig[:contact][:tax_number]
invoice_data[:payment_method] = payment_method
invoice_data[:order_articles] = {}
group_order.order_articles.each do |order_article|
# Get the result of last time ordering, if possible
goa = group_order.group_order_articles.detect { |tmp_goa| tmp_goa.order_article_id == order_article.id }
# Build hash with relevant data
invoice_data[:order_articles][order_article.id] = {
:price => order_article.article.fc_price,
:quantity => (goa ? goa.quantity : 0),
:total_price => (goa ? goa.total_price : 0),
:tax => order_article.article.tax
}
end
invoice_data
end
end

View file

@ -0,0 +1,3 @@
/ insert_after 'erb:contains("phone")'
- if FoodsoftAutomaticInvoices.enabled?
= config_input c, :tax_number, input_html: {class: 'input-medium'}

View file

@ -0,0 +1,8 @@
/ insert_after 'erb:contains(":use_self_service")'
- if FoodsoftAutomaticInvoices.enabled?
%h4= t '.group_order_invoices'
= form.fields_for :group_order_invoices do |field|
= config_input field, :use_automatic_invoices, as: :boolean
= config_input field, :separate_deposits, as: :boolean
= config_input field, :vat_exempt, as: :boolean
= config_input field, :payment_method, as: :string, input_html: {class: 'input-medium'}

View file

@ -0,0 +1,3 @@
/ insert_after 'erb:contains(":contact_person")'
- if FoodsoftAutomaticInvoices.enabled?
= f.input :customer_number

View file

@ -0,0 +1,27 @@
if FoodsoftAutomaticInvoices.enabled?
Finance::BalancingController.class_eval do
def close
@order = Order.find(params[:id])
@type = FinancialTransactionType.find_by_id(params.permit(:type)[:type])
@order.close!(@current_user, @type)
note = t('finance.balancing.close.notice')
if @order.closed?
alert = t('finance.balancing.close.alert')
if FoodsoftConfig[:group_order_invoices]&.[](:use_automatic_invoices)
@order.group_orders.each do |go|
alert = t('finance.balancing.close.settings_not_set')
goi = GroupOrderInvoice.find_or_create_by!(group_order_id: go.id)
if goi.save!
NotifyGroupOrderInvoiceJob.perform_later(goi)
note = t('finance.balancing.close.notice_mail')
end
end
end
end
alert ||= t('finance.balancing.close.alert')
redirect_to finance_order_index_url, notice: note
rescue => error
redirect_to new_finance_order_url(order_id: @order.id), notice: note, alert: alert, msg: error.message
end
end
end

View file

@ -0,0 +1,4 @@
/ insert_after 'erb:contains(":updated_by")'
- if FoodsoftAutomaticInvoices.enabled?
%th= heading_helper GroupOrderInvoice, :name
%th

View file

@ -0,0 +1,10 @@
/ insert_after 'erb:contains("show_user(order.updated_by)")'
- if FoodsoftAutomaticInvoices.enabled?
%td
- if order.closed?
-if FoodsoftConfig[:contact][:tax_number] && order.ordergroups.present?
= render :partial => 'group_order_invoices/links', locals:{order: order}
-else
= I18n.t('activerecord.attributes.group_order_invoice.tax_number_not_set')
- else
= t('orders.index.not_closed')

View file

@ -0,0 +1,3 @@
/ replace 'erb:contains("edit_results_by_articles")'
- if FoodsoftAutomaticInvoices.enabled?
= render :partial => 'finance/balancing/edit_results_by_articles_override'

View file

@ -0,0 +1,21 @@
/ replace_contents "tr.gross-amount"
- if FoodsoftConfig[:group_order_invoices]&.[](:separate_deposits)
%tr
%td= t('.gross_amount')
%td.numeric= number_to_currency(order.sum(:gross_without_deposit))
%tr
%td= t('.fc_amount_without_deposit')
%td.numeric= number_to_currency(order.sum(:fc_without_deposit))
%tr
%td= t('.deposit')
%td.numeric= number_to_currency(order.sum(:deposit))
%tr
%td= t('.net_deposit')
%td.numeric= number_to_currency(order.sum(:net_deposit))
%tr
%td= t('.fc_deposit')
%td.numeric= number_to_currency(order.sum(:fc_deposit))
- else
%tr
%td= t('.gross_amount')
%td.numeric= number_to_currency(order.sum(:gross))

View file

@ -0,0 +1,15 @@
if FoodsoftAutomaticInvoices.enabled?
Finance::BalancingHelper.class_eval do
def balancing_view_partial
view = params[:view] || 'edit_results'
case view
when 'edit_results'
'edit_results_by_articles_override'
when 'groups_overview'
'shared/articles_by/groups'
when 'articles_overview'
'shared/articles_by/articles'
end
end
end
end

View file

@ -0,0 +1,20 @@
if FoodsoftAutomaticInvoices.enabled?
Mailer.class_eval do
# Sends automatically generated invoicesfor group orders to ordergroup members
def add_group_order_invoice_attachments(group_order_invoice)
attachment_name = group_order_invoice.name + '.pdf'
attachments[attachment_name] = GroupOrderInvoicePdf.new(group_order_invoice.load_data_for_invoice).to_pdf
end
def group_order_invoice(group_order_invoice, user)
@user = user
@group_order_invoice = group_order_invoice
@group_order = group_order_invoice.group_order
@supplier = @group_order.order.supplier.name
@group = @group_order.ordergroup
add_group_order_invoice_attachments(group_order_invoice)
mail to: user,
subject: I18n.t('mailer.group_order_invoice.subject', group: @group.name, supplier: @supplier)
end
end
end

View file

@ -0,0 +1,31 @@
if FoodsoftAutomaticInvoices.enabled?
PriceCalculation.class_eval do
# deposit is always gross
def gross_price
add_percent(price, tax) + deposit
end
def gross_price_without_deposit
add_percent(price, tax)
end
def net_deposit_price
remove_percent(deposit, tax)
end
def fc_price_without_deposit
add_percent(gross_price_without_deposit, FoodsoftConfig[:price_markup].to_i)
end
def fc_deposit_price
add_percent(deposit, FoodsoftConfig[:price_markup].to_i)
end
private
def remove_percent(value, percent)
(value / ((percent * 0.01) + 1)).round(2)
end
end
end

View file

@ -0,0 +1,15 @@
if FoodsoftAutomaticInvoices.enabled?
GroupOrderArticle.class_eval do
def total_price_without_deposit(order_article = self.order_article)
if order_article.order.open?
if FoodsoftConfig[:tolerance_is_costly]
order_article.price.fc_price_without_deposit * (quantity + tolerance)
else
order_article.price.fc_price_without_deposit * quantity
end
else
order_article.price.fc_price_without_deposit * result
end
end
end
end

View file

@ -0,0 +1,5 @@
if FoodsoftAutomaticInvoices.enabled?
GroupOrder.class_eval do
has_one :group_order_invoice
end
end

View file

@ -0,0 +1,15 @@
if FoodsoftAutomaticInvoices.enabled?
OrderArticle.class_eval do
def total_gross_price_without_deposit
units * price.unit_quantity * price.gross_price_without_deposit
end
def total_deposit_price
units * price.unit_quantity * price.deposit
end
def total_price_without_deposit
units * price.unit_quantity * price.fc_price_without_deposit
end
end
end

View file

@ -0,0 +1,50 @@
if FoodsoftAutomaticInvoices.enabled?
class Order < ApplicationRecord
# Returns the all round price of a finished order
# :groups returns the sum of all GroupOrders
# :clear returns the price without tax, deposit and markup
# :gross includes tax and deposit(gross). this amount should be equal to suppliers bill
# :gross_without_deposit excludes the depost from the gross price
# :net_deposit returns the deposit without tax (deposit entered by user is default gross)
# :fc_deposit_price returns the deposit with markup
# :fc, guess what...
def sum(type = :gross)
total = 0
if %i[net gross net_deposit gross_without_deposit fc_without_deposit fc_deposit deposit fc].include?(type)
for oa in order_articles.ordered.includes(:article, :article_price)
quantity = oa.units * oa.price.unit_quantity
case type
when :net
total += quantity * oa.price.price
when :gross
total += quantity * oa.price.gross_price
when :gross_without_deposit
total += quantity * oa.price.gross_price_without_deposit
when :fc
total += quantity * oa.price.fc_price
when :fc_without_deposit
total += quantity * oa.price.fc_price_without_deposit
when :net_deposit
total += quantity * oa.price.net_deposit_price
when :fc_deposit
total += quantity * oa.price.fc_deposit_price
when :deposit
total += quantity * oa.price.deposit
end
end
elsif %i[groups groups_without_markup].include?(type)
for go in group_orders.includes(group_order_articles: { order_article: %i[article article_price] })
for goa in go.group_order_articles
case type
when :groups
total += goa.result * goa.order_article.price.fc_price
when :groups_without_markup
total += goa.result * goa.order_article.price.gross_price
end
end
end
end
total
end
end
end

View file

@ -0,0 +1,6 @@
/ insert_after 'erb:contains(":contact_person")'
- if FoodsoftAutomaticInvoices.enabled?
%p
= f.label :customer_number
%br/
= f.text_field :customer_number

View file

@ -0,0 +1,5 @@
/ insert_before 'erb:contains("t \'.prices\'")'
- if FoodsoftAutomaticInvoices.enabled?
- if FoodsoftConfig[:group_order_invoices]&.[](:separate_deposits)
%th= t '.deposit'

View file

@ -0,0 +1,5 @@
/ insert_after 'erb:contains("number_to_currency(gross_price)")'
- if FoodsoftAutomaticInvoices.enabled?
- if FoodsoftConfig[:group_order_invoices]&.[](:separate_deposits)
%td= number_to_currency(order_article.price.deposit)

View file

@ -0,0 +1,5 @@
/ insert_after 'erb:contains(".description2")'
- if FoodsoftAutomaticInvoices.enabled?
- if FoodsoftConfig[:group_order_invoices]&.[](:separate_deposits)
= t '.description3', net_deposit: number_to_currency(@order.sum(:net_deposit)), deposit: number_to_currency(@order.sum(:deposit))

View file

@ -0,0 +1,4 @@
/ insert_after 'erb:contains(" group.contact_address")'
- if FoodsoftAutomaticInvoices.enabled?
%dt= heading_helper(Ordergroup, :customer_number) + ':'
%dd=h group.customer_number

View file

@ -0,0 +1,65 @@
-# :javascript destroy deface functionality
- content_for :javascript do
:javascript
$(function() {
// create List for search-feature (using list.js, http://listjs.com)
var listjsResetPlugin = ['reset', {highlightClass: 'btn-primary'}];
var listjsDelayPlugin = ['delay', {delayedSearchTime: 500}];
new List(document.body, {
valueNames: ['name'],
engine: 'unlist',
plugins: [listjsResetPlugin, listjsDelayPlugin],
// make large pages work too (as we don't have paging - articles may disappear!)
page: 10000,
indexAsync: true
});
$('input').keydown(function(event){
if(event.keyCode == 13) {
event.preventDefault();
return false;
}
});
});
%table.ordered-articles.table.table-striped
%thead
%tr
%th
.input-append
= text_field_tag :article, params[:article], placeholder: (heading_helper Article, :name), class: 'delayed-search resettable search-query'
%th= heading_helper Article, :order_number
%th= t('.amount')
%th= heading_helper Article, :unit
%th= t('.net')
%th= t('.gross')
%th= t('.fc')
%th= heading_helper Article, :deposit
%th= heading_helper Article, :tax
%th{:colspan => "2"}
- unless @order.closed?
.btn-group
= link_to t('.add_article'), new_order_order_article_path(@order), remote: true,
class: 'btn btn-small'
= link_to '#', data: {toggle: 'dropdown'}, class: 'btn btn-small dropdown-toggle' do
%span.caret
%ul.dropdown-menu
%li= link_to t('.add_article'), new_order_order_article_path(@order), remote: true
%li= link_to t('.edit_transport'), edit_transport_finance_order_path(@order), remote: true
%tbody.list#result_table
- for order_article in @articles.select { |oa| oa.units > 0 }
= render :partial => "order_article_result_override", :locals => {:order_article => order_article}
%tr
%td{ colspan: 10 } The following were not ordered
- for order_article in @articles.select { |oa| oa.units == 0 }
= render :partial => "order_article_result_override", :locals => {:order_article => order_article}
- if @order.transport
%tr
%td{ colspan: 5 }= heading_helper Order, :transport
%td{ colspan: 3, data: {value: @order.transport} }= number_to_currency(@order.transport)
%td= link_to t('ui.edit'), edit_transport_finance_order_path(@order), remote: true,
class: 'btn btn-mini' unless order_article.order.closed?

View file

@ -0,0 +1,42 @@
%td.closed.name
= link_to order_article.article.name, '#', 'data-toggle-this' => "#group_order_articles_#{order_article.id}"
%td= order_article.article.order_number
%td{title: units_history_line(order_article, :plain => true)}
= order_article.units
= pkg_helper order_article.article_price
- if s=order_article.ordered_quantities_different_from_group_orders?
%span{:style => "color:red;font-weight: bold"}= s
%td #{order_article.article.unit}
%td
= number_to_currency(order_article.price.price, :unit => "")
:plain
/
= number_to_currency(order_article.total_price, :unit => "")
%td
- if FoodsoftConfig[:group_order_invoices]&.[](:separate_deposits)
= number_to_currency(order_article.price.gross_price_without_deposit, :unit => "")
:plain
/
= number_to_currency(order_article.total_gross_price_without_deposit, :unit => "")
-else
= number_to_currency(order_article.price.gross_price, :unit => "")
:plain
/
= number_to_currency(order_article.total_gross_price, :unit => "")
%td
= number_to_currency(order_article.price.fc_price_without_deposit, :unit => "")
:plain
/
= number_to_currency(order_article.total_price_without_deposit, :unit => "")
%td
= number_to_currency(order_article.price.deposit, :unit => "") unless order_article.price.deposit.zero?
:plain
/
= number_to_currency(order_article.total_deposit_price, :unit => "") unless order_article.price.deposit.zero?
%td= number_to_percentage(order_article.price.tax) unless order_article.price.tax.zero?
%td
= link_to t('ui.edit'), edit_order_order_article_path(order_article.order, order_article), remote: true,
class: 'btn btn-mini' unless order_article.order.closed?
%td
= link_to t('ui.delete'), order_order_article_path(order_article.order, order_article), method: :delete,
remote: true, data: {confirm: t('.confirm')}, class: 'btn btn-danger btn-mini' unless order_article.order.closed?

View file

@ -0,0 +1,5 @@
%tr[order_article]
= render :partial => 'finance/balancing/order_article_override', :locals => {:order_article => order_article}
%tr{:id => "group_order_articles_#{order_article.id}", :class => "results", :style => "display:none"}
= render :partial => 'finance/balancing/group_order_articles', :locals => {:order_article => order_article}

View file

@ -0,0 +1,29 @@
.row
.column.small-12
- show_generate_with_date = true
- order.group_orders.each do |go|
- if go.group_order_invoice.present?
- show_generate_with_date = false
- if show_generate_with_date
= form_for :group_order_invoice, url: url_for('group_order_invoice#create_multiple'), remote: true do |f|
= f.label :invoice_date, I18n.t('activerecord.attributes.group_order_invoice.links.invoice_date')
= f.date_field :invoice_date, {value: Date.today, max: Date.today, required: true}
= f.hidden_field :order_id, value: order.id
= f.submit I18n.t('activerecord.attributes.group_order_invoice.links.generate_with_date'), class: 'btn btn small'
- order.group_orders.includes([:group_order_invoice, :ordergroup]).each do |go|
.row
.column.small-3
= label_tag go.ordergroup.name
- if go.group_order_invoice
.column.small-3
= link_to I18n.t('activerecord.attributes.group_order_invoice.links.download'), group_order_invoice_path(go.group_order_invoice, :format => 'pdf'), class: 'btn btn-small'
.column.small-3
= link_to I18n.t('activerecord.attributes.group_order_invoice.links.delete'), go.group_order_invoice, method: :delete, class: 'btn btn-danger btn-small', remote: true
- else
= button_to I18n.t('activerecord.attributes.group_order_invoice.links.generate'), group_order_invoices_path(:method => :post, group_order: go) ,class: 'btn btn-small', params: {id: order.id}, remote: true
- if order.group_orders.map(&:group_order_invoice).compact.present?
%br/
.row
.column.small-3
= link_to I18n.t('activerecord.attributes.group_order_invoice.links.download_all_zip'), download_all_group_order_invoices_path(order), class: 'btn btn-small'

View file

@ -0,0 +1 @@
$("#generate-invoice<%= params[:id] %>").html("<%= escape_javascript(render partial: 'links', locals: {order: @order}) %>");

View file

@ -0,0 +1 @@
$("#generate-invoice<%= @order.id %>").html("<%= escape_javascript(render partial: 'links', locals: {order: @order}) %>");

View file

@ -0,0 +1 @@
$("#generate-invoice<%= @order.id %>").html("<%= escape_javascript(render partial: 'links', locals: {order: @order}) %>");

View file

@ -0,0 +1 @@
= raw t '.text', group: @group.name, supplier: @supplier , foodcoop: FoodsoftConfig[:name]

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
Rails.application.routes.draw do
post 'finance/group_order_invoice', to: 'group_order_invoices#create_multiple'
get 'orders/:order_id/group_order_invoices/download_all', to: 'group_order_invoices#download_all', as: 'download_all_group_order_invoices'
resources :group_order_invoices
end

View file

@ -0,0 +1,13 @@
class CreateGroupOrderInvoices < ActiveRecord::Migration[5.2]
def change
create_table :group_order_invoices do |t|
t.integer :group_order_id
t.bigint :invoice_number, unique: true, limit: 8
t.date :invoice_date
t.string :payment_method
t.timestamps
end
add_index :group_order_invoices, :group_order_id, unique: true
end
end

View file

@ -0,0 +1,5 @@
class AddCustomerNumberToGroup < ActiveRecord::Migration[7.0]
def change
add_column :groups, :customer_number, :string, unique: true
end
end

View file

@ -0,0 +1,19 @@
$:.push File.expand_path('lib', __dir__)
# Maintain your gem's version:
require 'foodsoft_automatic_invoices/version'
# Describe your gem and declare its dependencies:
Gem::Specification.new do |s|
s.name = 'foodsoft_automatic_invoices'
s.version = FoodsoftAutomaticInvoices::VERSION
s.authors = ['viehlieb']
s.email = ['pf@pragma-shift.net']
s.summary = "Foodsoft plugin to enhance foodsoft's accounting capabilities and to create and automatically deliver invoice pdfs for accounted orders."
s.description = ''
s.files = Dir['{app,config,db,spec,lib}/**/**/**/*'] + ['Rakefile', 'README.md']
s.add_dependency 'rails'
s.add_dependency 'deface', '~> 1.9'
end

View file

@ -0,0 +1,10 @@
require 'deface'
require 'foodsoft_automatic_invoices/engine'
require 'foodsoft_automatic_invoices/send_group_order_invoice_pdf'
module FoodsoftAutomaticInvoices
def self.enabled?
FoodsoftConfig[:use_automatic_invoices]
end
end

View file

@ -0,0 +1,12 @@
module FoodsoftAutomaticInvoices
class Engine < ::Rails::Engine
initializer 'automatic_invoices.assets.precompile' do |app|
app.config.assets.precompile += %w(group_orders.css.less)
end
def default_foodsoft_config(cfg)
cfg[:use_automatic_invoices] = false
end
end
end

View file

@ -0,0 +1,24 @@
module FoodsoftAutomaticInvoices
module SendGroupOrderInvoicePdf
extend ActiveSupport::Concern
protected
def create_invoice_pdf(group_order_invoice)
invoice_data = group_order_invoice.load_data_for_invoice
invoice_data[:title] = t('documents.group_order_invoice_pdf.title', supplier: invoice_data[:supplier])
invoice_data[:no_footer] = true
GroupOrderInvoicePdf.new invoice_data
end
def send_group_order_invoice_pdf(group_order_invoice)
pdf = create_invoice_pdf(group_order_invoice)
send_data pdf.to_pdf, filename: pdf.filename, type: 'application/pdf'
end
end
end
ActiveSupport.on_load(:after_initialize) do
Concerns::SendOrderPdf.include FoodsoftAutomaticInvoices::SendGroupOrderInvoicePdf
end

View file

@ -0,0 +1,3 @@
module FoodsoftAutomaticInvoices
VERSION = '0.0.1'
end

View file

@ -0,0 +1,7 @@
require 'factory_bot'
FactoryBot.define do
factory :group_order_invoice do
group_order { create :group_order }
end
end

View file

@ -0,0 +1,72 @@
require_relative '../spec_helper'
feature GroupOrderInvoice, js: true do
let(:admin) { create :user, groups: [create(:workgroup, role_finance: true)] }
let(:user) { create :user, groups: [create(:ordergroup)] }
let(:article) { create :article, unit_quantity: 1 }
let(:order) { create :order, supplier: article.supplier, article_ids: [article.id], ends: Time.now } # need to ref article
let(:go) { create :group_order, order: order, ordergroup: user.ordergroup}
let(:oa) { order.order_articles.find_by_article_id(article.id) }
let(:ftt) { create :financial_transaction_type }
let(:goa) { create :group_order_article, group_order: go, order_article: oa }
include ActiveJob::TestHelper
before { login admin }
after { clear_enqueued_jobs }
it 'does not enqueue MailerJob when order is settled if tax_number or options not set' do
goa.update_quantities 2, 0
oa.update_results!
visit confirm_finance_order_path(id: order.id)
click_link_or_button I18n.t('finance.balancing.confirm.clear')
expect(NotifyGroupOrderInvoiceJob).not_to have_been_enqueued
end
it 'enqueues MailerJob when order is settled if tax_number or options are set' do
goa.update_quantities 2, 0
oa.update_results!
order.reload
FoodsoftConfig[:group_order_invoices] = { use_automatic_invoices: true }
FoodsoftConfig[:contact][:tax_number] = 12_345_678
visit confirm_finance_order_path(id: order.id, type: ftt)
expect(page).to have_selector(:link_or_button, I18n.t('finance.balancing.confirm.clear'))
click_link_or_button I18n.t('finance.balancing.confirm.clear')
expect(NotifyGroupOrderInvoiceJob).to have_been_enqueued
end
it 'generates Group Order Invoice when order is closed if tax_number is set' do
goa.update_quantities 2, 0
oa.update_results!
FoodsoftConfig[:contact][:tax_number] = 12_345_678
order.update!(state: 'closed')
go.reload
order.reload
visit finance_order_index_path
expect(page).to have_selector(:link_or_button, I18n.t('activerecord.attributes.group_order_invoice.links.generate'))
click_link_or_button I18n.t('activerecord.attributes.group_order_invoice.links.generate')
expect(GroupOrderInvoice.all.count).to eq(1)
end
it 'generates multiple Group Order Invoice for order when order is closed if tax_number is set' do
goa.update_quantities 2, 0
oa.update_results!
FoodsoftConfig[:contact][:tax_number] = 12_345_678
order.update!(state: 'closed')
order.reload
visit finance_order_index_path
expect(page).to have_selector(:link_or_button, I18n.t('activerecord.attributes.group_order_invoice.links.generate_with_date'))
click_link_or_button I18n.t('activerecord.attributes.group_order_invoice.links.generate_with_date')
expect(GroupOrderInvoice.all.count).to eq(1)
end
it 'does not generate Group Order Invoice when order is closed if tax_number not set' do
goa.update_quantities 2, 0
oa.update_results!
order.update!(state: 'closed')
order.reload
visit finance_order_index_path
expect(page).to have_content(I18n.t('activerecord.attributes.group_order_invoice.tax_number_not_set'))
end
end

View file

@ -0,0 +1,168 @@
require_relative '../spec_helper'
describe Article do
let(:supplier) { create(:supplier) }
let(:article) { create(:article, supplier: supplier) }
it 'has a unique name' do
article2 = build(:article, supplier: supplier, name: article.name)
expect(article2).to be_invalid
end
it 'can be deleted' do
expect(article).not_to be_deleted
article.mark_as_deleted
expect(article).to be_deleted
end
describe 'convert units' do
it 'returns nil when equal' do
expect(article.convert_units(article)).to be_nil
end
it 'returns false when invalid unit' do
article1 = build(:article, supplier: supplier, unit: 'invalid')
expect(article.convert_units(article1)).to be false
end
it 'returns false if unit = 0' do
article1 = build(:article, supplier: supplier, unit: '1kg', price: 2, unit_quantity: 1)
article2 = build(:article, supplier: supplier, unit: '0kg', price: 2, unit_quantity: 1)
expect(article1.convert_units(article2)).to be false
end
it 'returns false if unit becomes zero because of , symbol in unit format' do
article1 = build(:article, supplier: supplier, unit: '0,8kg', price: 2, unit_quantity: 1)
article2 = build(:article, supplier: supplier, unit: '0,9kg', price: 2, unit_quantity: 1)
expect(article1.convert_units(article2)).to be false
end
it 'converts from ST to KI (german foodcoops legacy)' do
article1 = build(:article, supplier: supplier, unit: 'ST')
article2 = build(:article, supplier: supplier, name: 'banana 10-12 St', price: 12.34, unit: 'KI')
new_price, new_unit_quantity = article1.convert_units(article2)
expect(new_unit_quantity).to eq 10
expect(new_price).to eq 1.23
end
it 'converts from g to kg' do
article1 = build(:article, supplier: supplier, unit: 'kg')
article2 = build(:article, supplier: supplier, unit: 'g', price: 0.12, unit_quantity: 1500)
new_price, new_unit_quantity = article1.convert_units(article2)
expect(new_unit_quantity).to eq 1.5
expect(new_price).to eq 120
end
end
it 'computes changed article attributes' do
article2 = build(:article, supplier: supplier, name: 'banana')
expect(article.unequal_attributes(article2)[:name]).to eq 'banana'
end
it 'computes the gross price correctly' do
article.deposit = 0
article.tax = 12
expect(article.gross_price).to eq((article.price * 1.12).round(2))
article.deposit = 1.20
if FoodsoftConfig[:group_order_invoices]&.[](:separate_deposits)
expect(article.gross_price_without_deposit).to eq((article.price * 1.12 + 1.20).round(2))
expect(article.gross_price).to eq(((article.price + 1.20) * 1.12).round(2))
end
end
it 'gross price >= net price' do
expect(article.gross_price).to be >= article.price
end
[[nil, 1],
[0, 1],
[5, 1.05],
[42, 1.42],
[100, 2]].each do |price_markup, percent|
it "computes the fc price with price_markup #{price_markup} correctly" do
FoodsoftConfig.config['price_markup'] = price_markup
expect(article.fc_price).to eq((article.gross_price * percent).round(2))
end
end
it 'knows when it is deleted' do
expect(supplier.deleted?).to be false
supplier.mark_as_deleted
expect(supplier.deleted?).to be true
end
it 'keeps a price history' do
expect(article.article_prices.map(&:price)).to eq([article.price])
oldprice = article.price
sleep 1 # so that the new price really has a later creation time
article.price += 1
article.save!
expect(article.article_prices.reload.map(&:price)).to eq([article.price, oldprice])
end
it 'is not in an open order by default' do
expect(article.in_open_order).to be_nil
end
it 'is knows its open order' do
order = create(:order, supplier: supplier, article_ids: [article.id])
expect(article.in_open_order).to eq(order)
end
it 'has no shared article by default' do
expect(article.shared_article).to be_nil
end
describe 'connected to a shared database', type: :feature do
let(:shared_article) { create(:shared_article) }
let(:supplier) { create(:supplier, shared_supplier_id: shared_article.supplier_id) }
let(:article) { create(:article, supplier: supplier, order_number: shared_article.order_number) }
it 'can be found in the shared database' do
expect(article.shared_article).not_to be_nil
end
it 'can find updates' do
changed = article.shared_article_changed?
expect(changed).not_to be_falsey
expect(changed.length).to be > 1
end
it 'can be synchronised' do
# TODO: move article sync from supplier to article
article # need to reference for it to exist when syncing
updated_article = supplier.sync_all[0].select { |s| s[0].id == article.id }.first[0]
article.update(updated_article.attributes.reject { |k, _v| %w[id type].include?(k) })
expect(article.name).to eq(shared_article.name)
# now synchronising shouldn't change anything anymore
expect(article.shared_article_changed?).to be_falsey
end
it 'does not need to synchronise an imported article' do
article = shared_article.build_new_article(supplier)
article.article_category = create :article_category
expect(article.shared_article_changed?).to be_falsey
end
it 'adapts to foodcoop units when synchronising' do
shared_article.unit = '1kg'
shared_article.unit_quantity = 1
shared_article.save!
article = shared_article.build_new_article(supplier)
article.article_category = create :article_category
article.unit = '200g'
article.shared_updated_on -= 1 # to make update do something
article.save!
# TODO: get sync functionality in article
updated_article = supplier.sync_all[0].select { |s| s[0].id == article.id }.first[0]
article.update!(updated_article.attributes.reject { |k, _v| %w[id type].include?(k) })
expect(article.unit).to eq '200g'
expect(article.unit_quantity).to eq 5
expect(article.price).to be_within(0.005).of(shared_article.price / 5)
end
it 'does not synchronise when it has no order number' do
article.update(order_number: nil)
expect(supplier.sync_all).to eq [[], [], []]
end
end
end

View file

@ -0,0 +1,59 @@
require_relative '../spec_helper'
describe GroupOrderInvoice do
let(:user) { create :user, groups: [create(:ordergroup)] }
let(:supplier) { create :supplier }
let(:article) { create :article, supplier: supplier }
let(:order) { create :order }
let(:group_order) { create :group_order, order: order, ordergroup: user.ordergroup }
describe 'erroneous group order invoice' do
let(:goi) { create :group_order_invoice, group_order_id: group_order.id }
it 'does not create group order invoice if tax_number not set' do
expect { goi }.to raise_error(ActiveRecord::RecordInvalid, /.*/)
end
end
describe 'valid group order invoice' do
before do
FoodsoftConfig[:contact][:tax_number] = 123_457_8
end
invoice_number1 = Time.now.strftime("%Y%m%d") + '0001'
invoice_number2 = Time.now.strftime("%Y%m%d") + '0002'
let(:user2) { create :user, groups: [create(:ordergroup)] }
let(:goi1) { create :group_order_invoice, group_order_id: group_order.id }
let(:goi2) { create :group_order_invoice, group_order_id: group_order.id }
let(:group_order2) { create :group_order, order: order, ordergroup: user2.ordergroup }
let(:goi3) { create :group_order_invoice, group_order_id: group_order2.id }
let(:goi4) { create :group_order_invoice, group_order_id: group_order2.id, invoice_number: invoice_number1 }
it 'creates group order invoice if tax_number is set' do
expect(goi1).to be_valid
end
it 'sets invoice_number according to date' do
number = Time.now.strftime("%Y%m%d") + '0001'
expect(goi1.invoice_number).to eq(number.to_i)
end
it 'fails to create if group_order_id is used multiple times for creation' do
expect(goi1.group_order.id).to eq(group_order.id)
expect { goi2 }.to raise_error(ActiveRecord::RecordNotUnique)
end
it 'creates two different group order invoice with different invoice_numbers' do
expect(goi1.invoice_number).to eq(invoice_number1.to_i)
expect(goi3.invoice_number).to eq(invoice_number2.to_i)
end
it 'fails to create two different group order invoice with same invoice_numbers' do
goi1
expect { goi4 }.to raise_error(ActiveRecord::RecordInvalid)
end
end
end

View file

@ -14,25 +14,32 @@ feature 'settling an order', js: true do
let(:goa2) { create(:group_order_article, group_order: go2, order_article: oa) }
before do
Rails.cache.clear
login admin
goa1.update_quantities(3, 0)
goa2.update_quantities(1, 0)
oa.update_results!
oa.reload
order.reload
order.finish!(admin)
goa1.reload
goa2.reload
visit new_finance_order_path(order_id: order.id)
end
before { visit new_finance_order_path(order_id: order.id) }
before { login admin }
after do
Rails.cache.clear
end
it 'has correct order result' do
oa.reload
expect(oa.quantity).to eq(4)
expect(oa.tolerance).to eq(0)
expect(goa1.result).to eq(3)
expect(goa2.result).to eq(1)
end
it 'has product ordered visible' do
it 'has product ordered visible', js: true do
expect(page).to have_content(article.name)
expect(page).to have_selector("#order_article_#{oa.id}")
end

View file

@ -170,8 +170,10 @@ describe Order do
oa.update_results!
order.finish!(user)
goa.reload
order.reload
order.close!(user, ftt)
goa.reload
end
it 'creates financial transaction with correct amount' do

View file

@ -40,6 +40,13 @@ RSpec.configure do |config|
FoodsoftConfig.init_mailing
end
config.before(:each, type: :feature) do
Rails.cache.clear
end
config.after(:each, type: :feature) do
Rails.cache.clear
end
# If true, the base class of anonymous controllers will be inferred
# automatically. This will be the default behavior in future versions of
# rspec-rails.