From 93143c28f255f4b18a3a5001e2583fb50e08e19b Mon Sep 17 00:00:00 2001 From: Philipp Rothmann Date: Mon, 24 Jul 2023 10:50:35 +0200 Subject: [PATCH] merge automatic group order invoice generation see https://github.com/foodcoops/foodsoft/pull/907 for reference and original work by viehlieb Co-authored-by: viehlieb fix PDF Pdf make explicit deposit in invoices work add ordergroupname to invoice file name mark bold sum for vat exempt foodcoops download multiple group order invoice as zip --- .rubocop_todo.yml | 3 + .../bootstrap_and_overrides.css.less | 9 +- .../concerns/send_group_order_invoice_pdf.rb | 17 ++ .../finance/balancing_controller.rb | 23 +- .../group_order_invoices_controller.rb | 87 ++++++ app/documents/group_order_invoice_pdf.rb | 264 ++++++++++++++++++ app/jobs/notify_group_order_invoice_job.rb | 10 + app/lib/render_pdf.rb | 21 +- app/mailers/mailer.rb | 17 ++ app/models/concerns/price_calculation.rb | 16 ++ app/models/group_order.rb | 1 + app/models/group_order_article.rb | 12 + app/models/group_order_invoice.rb | 58 ++++ app/models/order.rb | 14 +- .../admin/configs/_tab_foodcoop.html.haml | 1 + .../admin/configs/_tab_payment.html.haml | 6 + app/views/admin/ordergroups/_form.html.haml | 1 + app/views/finance/balancing/_orders.html.haml | 10 + app/views/finance/balancing/_summary.haml | 10 + app/views/finance/balancing/index.html.haml | 1 - .../group_order_invoices/_links.html.haml | 29 ++ app/views/group_order_invoices/create.js.erb | 1 + .../create_multiple.js.erb | 1 + app/views/group_order_invoices/destroy.js.erb | 1 + app/views/group_orders/_form.html.haml | 4 +- .../mailer/group_order_invoice.text.haml | 1 + app/views/ordergroups/edit.html.haml | 4 + app/views/shared/_group.html.haml | 2 + config/locales/de.yml | 82 ++++++ config/locales/en.yml | 79 ++++++ config/routes.rb | 7 + ...11208142719_create_group_order_invoices.rb | 13 + ...0822120005_add_customer_number_to_group.rb | 5 + db/schema.rb | 109 ++++---- spec/factories/group_order_invoice.rb | 7 + spec/integration/group_order_invoices_spec.rb | 72 +++++ spec/models/group_order_invoice_spec.rb | 59 ++++ 37 files changed, 988 insertions(+), 69 deletions(-) create mode 100644 app/controllers/concerns/send_group_order_invoice_pdf.rb create mode 100644 app/controllers/group_order_invoices_controller.rb create mode 100644 app/documents/group_order_invoice_pdf.rb create mode 100644 app/jobs/notify_group_order_invoice_job.rb create mode 100644 app/models/group_order_invoice.rb create mode 100644 app/views/group_order_invoices/_links.html.haml create mode 100644 app/views/group_order_invoices/create.js.erb create mode 100644 app/views/group_order_invoices/create_multiple.js.erb create mode 100644 app/views/group_order_invoices/destroy.js.erb create mode 100644 app/views/mailer/group_order_invoice.text.haml create mode 100644 db/migrate/20211208142719_create_group_order_invoices.rb create mode 100644 db/migrate/20230822120005_add_customer_number_to_group.rb create mode 100644 spec/factories/group_order_invoice.rb create mode 100644 spec/integration/group_order_invoices_spec.rb create mode 100644 spec/models/group_order_invoice_spec.rb diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index cbbec263..5276a743 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -274,6 +274,8 @@ Lint/Void: # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: Max: 143 + Exclude: + - 'app/documents/group_order_invoice_pdf.rb' # Offense count: 13 # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns, inherit_mode. @@ -407,6 +409,7 @@ RSpec/Capybara/FeatureMethods: - 'spec/integration/receive_spec.rb' - 'spec/integration/session_spec.rb' - 'spec/integration/supplier_spec.rb' + - 'spec/integration/group_order_invoices_spec.rb' # Offense count: 4 RSpec/Capybara/SpecificMatcher: diff --git a/app/assets/stylesheets/bootstrap_and_overrides.css.less b/app/assets/stylesheets/bootstrap_and_overrides.css.less index 971308c9..ebd30b20 100644 --- a/app/assets/stylesheets/bootstrap_and_overrides.css.less +++ b/app/assets/stylesheets/bootstrap_and_overrides.css.less @@ -241,6 +241,9 @@ table { tr.order-article:hover .article-info { display: none; } + tr.order-article:focus .article-info { + display: none; + } } #order-footer { @@ -275,11 +278,13 @@ tr.order-article .article-info { display: none; } -tr.order-article:hover .article-info { +tr.order-article:focus{ + background-color: #E4EED6; +} +tr.order-article:focus .article-info { display: block; } - // ********* Articles tr.just-updated { diff --git a/app/controllers/concerns/send_group_order_invoice_pdf.rb b/app/controllers/concerns/send_group_order_invoice_pdf.rb new file mode 100644 index 00000000..76e71c99 --- /dev/null +++ b/app/controllers/concerns/send_group_order_invoice_pdf.rb @@ -0,0 +1,17 @@ +module Concerns::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 diff --git a/app/controllers/finance/balancing_controller.rb b/app/controllers/finance/balancing_controller.rb index e1a2dafb..29320c64 100644 --- a/app/controllers/finance/balancing_controller.rb +++ b/app/controllers/finance/balancing_controller.rb @@ -5,7 +5,7 @@ class Finance::BalancingController < Finance::BaseController def new @order = Order.find(params[:order_id]) - flash.now.alert = t('.alert') if @order.closed? + flash.now.alert = t('finance.balancing.new.alert') if @order.closed? && flash[:alert].blank? @comments = @order.comments @articles = @order.order_articles.ordered_or_member.includes(:article, :article_price, @@ -81,9 +81,24 @@ class Finance::BalancingController < Finance::BaseController @order = Order.find(params[:id]) @type = FinancialTransactionType.find_by_id(params.permit(:type)[:type]) @order.close!(@current_user, @type) - redirect_to finance_order_index_url, notice: t('.notice') - rescue StandardError => e - redirect_to new_finance_order_url(order_id: @order.id), alert: t('.alert', message: e.message) + 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 # Close the order directly, without automaticly updating ordergroups account balances diff --git a/app/controllers/group_order_invoices_controller.rb b/app/controllers/group_order_invoices_controller.rb new file mode 100644 index 00000000..2e5a8408 --- /dev/null +++ b/app/controllers/group_order_invoices_controller.rb @@ -0,0 +1,87 @@ +class GroupOrderInvoicesController < ApplicationController + include Concerns::SendGroupOrderInvoicePdf + before_action :authenticate_finance + + def show + begin + @group_order_invoice = GroupOrderInvoice.find(params[:id]) + if 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 + else + raise RecordInvalid + end + rescue ActiveRecord::RecordInvalid => error + redirect_back fallback_location: root_path, notice: 'Something went wrong', alert: I18n.t('errors.general_msg', msg: "#{error} " + I18n.t('errors.check_tax_number')) + end + 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 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 => error + redirect_back fallback_location: root_path, notice: 'Something went wrong', :alert => I18n.t('errors.general_msg', :msg => error) + end + + def download_all + order = Order.find(params[:order_id]) + + invoices = order.group_orders.map(&:group_order_invoice) + pdf = {} + + 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) + invoice_file = Tempfile.new("#{pdf.filename}") + File.open(invoice_file.path, 'w:ASCII-8BIT') do |file| + file.write(pdf.to_pdf) + end + zipfile.add("#{pdf.filename}", invoice_file.path) unless zipfile.find_entry("#{pdf.filename}") + end + end + + zip_data = File.read(temp_file.path) + + respond_to do |format| + format.html { + send_data(zip_data, type: 'application/zip', filename: "#{l order.ends, format: :file}-#{order.supplier.name}-#{order.id}.zip", disposition: 'attachment') + } + end + end +end diff --git a/app/documents/group_order_invoice_pdf.rb b/app/documents/group_order_invoice_pdf.rb new file mode 100644 index 00000000..899d6cf8 --- /dev/null +++ b/app/documents/group_order_invoice_pdf.rb @@ -0,0 +1,264 @@ +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 + + if separate_deposits + total_deposit = 0 + total_deposit_gross = 0 + + tax_hash_deposit_gross = Hash.new(0) # for summing up deposit gross prices grouped into vat percentage + tax_hash_deposit_net = Hash.new(0) # same here with gross prices + 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_gross = separate_deposits ? goa.total_price_without_deposit : 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_gross)] + + if separate_deposits && order_article.price.deposit > 0.0 + goa_deposit = goa.result * order_article.price.deposit + goa_total_deposit = goa.result * order_article.price.fc_deposit_price + + data << ["zzgl. Pfand", + goa.result.to_i, + number_to_currency(order_article.price.deposit), + number_to_currency(goa_deposit), + tax.to_s + '%', + number_to_currency(goa_total_deposit)] + + total_deposit += goa_deposit + total_deposit_gross += goa_total_deposit + + tax_hash_deposit_net[tax.to_i] += goa_deposit + tax_hash_deposit_gross[tax.to_i] += goa_total_deposit + end + + tax_hash_net[tax.to_i] += goa_total_net + tax_hash_gross[tax.to_i] += goa_total_gross + + total_net += goa_total_net + total_gross += goa_total_gross + 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 = [[nil, nil, nil, "Netto", "MwSt", "Brutto"]] + [7, 19].each do |key| + sum << [nil, nil, "Produkte mit #{key}%", number_to_currency(tax_hash_net[key]), number_to_currency(tax_hash_gross[key] - tax_hash_net[key]), number_to_currency(tax_hash_gross[key])] + sum << [nil, 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]), number_to_currency(tax_hash_deposit_gross[key])] if separate_deposits + end + + total_deposit_gross ||= 0 + sum << [nil, nil, nil, nil, I18n.t('documents.group_order_invoice_pdf.sum_to_pay_gross'), number_to_currency(total_gross + total_deposit_gross)] + + 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..6).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..6).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..6).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 diff --git a/app/jobs/notify_group_order_invoice_job.rb b/app/jobs/notify_group_order_invoice_job.rb new file mode 100644 index 00000000..1a17fe9a --- /dev/null +++ b/app/jobs/notify_group_order_invoice_job.rb @@ -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 diff --git a/app/lib/render_pdf.rb b/app/lib/render_pdf.rb index 2311e646..a4974fdf 100644 --- a/app/lib/render_pdf.rb +++ b/app/lib/render_pdf.rb @@ -70,7 +70,7 @@ class RenderPdf < Prawn::Document 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 @@ -84,11 +84,11 @@ class RenderPdf < Prawn::Document ) header = options[:title] || title - footer = I18n.l(Time.now, format: :long) + 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 = height_of(footer, size: FOOTER_FONT_SIZE, font: DEFAULT_FONT) + FOOTER_SPACE + 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) @@ -98,12 +98,15 @@ class RenderPdf < Prawn::Document 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 - 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 + + 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 diff --git a/app/mailers/mailer.rb b/app/mailers/mailer.rb index 90c8a062..2b06ce8e 100644 --- a/app/mailers/mailer.rb +++ b/app/mailers/mailer.rb @@ -51,6 +51,18 @@ class Mailer < ActionMailer::Base subject: I18n.t('mailer.welcome.subject') end + # Sends automatically generated invoicesfor group orders to ordergroup members + 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 + # Sends order result for specific Ordergroup def order_result(user, group_order) @order = group_order.order @@ -169,6 +181,11 @@ class Mailer < ActionMailer::Base attachments['order.csv'] = OrderCsv.new(order, options).to_csv end + 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 + # separate method to allow plugins to mess with the text def additonal_welcome_text(user); end diff --git a/app/models/concerns/price_calculation.rb b/app/models/concerns/price_calculation.rb index 8d56d671..47c28bc6 100644 --- a/app/models/concerns/price_calculation.rb +++ b/app/models/concerns/price_calculation.rb @@ -7,11 +7,27 @@ module PriceCalculation add_percent(price + deposit, tax) end + def gross_price_without_deposit + add_percent(price, tax) + end + + def gross_deposit_price + add_percent(deposit, tax) + end + # @return [Number] Price for the foodcoop-member. def fc_price add_percent(gross_price, FoodsoftConfig[:price_markup].to_i) end + def fc_price_without_deposit + add_percent(gross_price_without_deposit, FoodsoftConfig[:price_markup].to_i) + end + + def fc_deposit_price + add_percent(gross_deposit_price, FoodsoftConfig[:price_markup].to_i) + end + private def add_percent(value, percent) diff --git a/app/models/group_order.rb b/app/models/group_order.rb index 183b663a..4768a0bd 100644 --- a/app/models/group_order.rb +++ b/app/models/group_order.rb @@ -9,6 +9,7 @@ class GroupOrder < ApplicationRecord has_many :group_order_articles, dependent: :destroy has_many :order_articles, through: :group_order_articles has_one :financial_transaction + has_one :group_order_invoice belongs_to :updated_by, optional: true, class_name: 'User', foreign_key: 'updated_by_user_id' validates :order_id, presence: true diff --git a/app/models/group_order_article.rb b/app/models/group_order_article.rb index 7b95d462..f5f42789 100644 --- a/app/models/group_order_article.rb +++ b/app/models/group_order_article.rb @@ -208,6 +208,18 @@ class GroupOrderArticle < ApplicationRecord end end + 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 + # Check if the result deviates from the result_computed def result_manually_changed? result != result_computed unless result.nil? diff --git a/app/models/group_order_invoice.rb b/app/models/group_order_invoice.rb new file mode 100644 index 00000000..21557161 --- /dev/null +++ b/app/models/group_order_invoice.rb @@ -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 diff --git a/app/models/order.rb b/app/models/order.rb index ada62e59..e275519b 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -207,7 +207,7 @@ class Order < ApplicationRecord # :fc, guess what... def sum(type = :gross) total = 0 - if %i[net gross fc].include?(type) + if %i[net gross gross_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 @@ -217,6 +217,12 @@ class Order < ApplicationRecord total += quantity * oa.price.gross_price when :fc total += quantity * oa.price.fc_price + when :gross_deposit + total += quantity * oa.price.gross_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) @@ -224,7 +230,11 @@ class Order < ApplicationRecord for goa in go.group_order_articles case type when :groups - total += goa.result * goa.order_article.price.fc_price + total += if FoodsoftConfig[:group_order_invoices]&.[](:separate_deposits) + goa.result * (goa.order_article.price.fc_price + goa.order_article.price.fc_deposit_price) + else + goa.result * goa.order_article.price.fc_price + end when :groups_without_markup total += goa.result * goa.order_article.price.gross_price end diff --git a/app/views/admin/configs/_tab_foodcoop.html.haml b/app/views/admin/configs/_tab_foodcoop.html.haml index 5aea80c2..efea4d81 100644 --- a/app/views/admin/configs/_tab_foodcoop.html.haml +++ b/app/views/admin/configs/_tab_foodcoop.html.haml @@ -7,4 +7,5 @@ = config_input c, :country, as: :string, input_html: {class: 'input-xlarge'} = config_input c, :email, required: true, input_html: {class: 'input-xlarge'} = config_input c, :phone, input_html: {class: 'input-medium'} + = config_input c, :tax_number, input_html: {class: 'input-medium'} = config_input form, :homepage, required: true, as: :url, input_html: {class: 'input-xlarge'} diff --git a/app/views/admin/configs/_tab_payment.html.haml b/app/views/admin/configs/_tab_payment.html.haml index 3fd7ca0a..70291110 100644 --- a/app/views/admin/configs/_tab_payment.html.haml +++ b/app/views/admin/configs/_tab_payment.html.haml @@ -13,6 +13,12 @@ = config_input form, :charge_members_manually, as: :boolean = config_input form, :use_iban, as: :boolean = config_input form, :use_self_service, as: :boolean +%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'} %h4= t '.schedule_title' = form.simple_fields_for :order_schedule do |fields| diff --git a/app/views/admin/ordergroups/_form.html.haml b/app/views/admin/ordergroups/_form.html.haml index 3eb3a9f5..128338bb 100644 --- a/app/views/admin/ordergroups/_form.html.haml +++ b/app/views/admin/ordergroups/_form.html.haml @@ -2,6 +2,7 @@ %p= t('.first_paragraph', url: link_to(t('.here'), new_invite_path(id: @ordergroup.id), remote: true)).html_safe = simple_form_for [:admin, @ordergroup] do |f| - captured = capture do + = f.input :customer_number = f.input :contact_person = f.input :contact_phone = f.input :contact_address diff --git a/app/views/finance/balancing/_orders.html.haml b/app/views/finance/balancing/_orders.html.haml index 3f20d850..dddb00c2 100644 --- a/app/views/finance/balancing/_orders.html.haml +++ b/app/views/finance/balancing/_orders.html.haml @@ -9,6 +9,8 @@ %th= t('.end') %th= t('.state') %th= heading_helper Order, :updated_by + %th= heading_helper GroupOrderInvoice, :name + %th %th %tbody - @orders.each do |order| @@ -17,6 +19,14 @@ %td=h format_time(order.ends) unless order.ends.nil? %td= order.closed? ? t('.cleared', amount: number_to_currency(order.foodcoop_result)) : t('.ended') %td= show_user(order.updated_by) + %td{id: "generate-invoice#{order.id}"} + - 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') %td - unless order.closed? - if current_user.role_orders? diff --git a/app/views/finance/balancing/_summary.haml b/app/views/finance/balancing/_summary.haml index e466727f..6516aa69 100644 --- a/app/views/finance/balancing/_summary.haml +++ b/app/views/finance/balancing/_summary.haml @@ -12,6 +12,16 @@ %tr %td= t('.fc_amount') %td.numeric= number_to_currency(order.sum(:fc)) + - if FoodsoftConfig[:group_order_invoices]&.[](:separate_deposits) + %tr + %td= t('.deposit') + %td.numeric= number_to_currency(order.sum(:deposit)) + %tr + %td= t('.gross_deposit') + %td.numeric= number_to_currency(order.sum(:gross_deposit)) + %tr + %td= t('.fc_deposit') + %td.numeric= number_to_currency(order.sum(:fc_deposit)) %tr %td= t('.groups_amount') %td.numeric= number_to_currency(order.sum(:groups)) diff --git a/app/views/finance/balancing/index.html.haml b/app/views/finance/balancing/index.html.haml index 1d1fd8b5..4a7fd119 100644 --- a/app/views/finance/balancing/index.html.haml +++ b/app/views/finance/balancing/index.html.haml @@ -1,5 +1,4 @@ - title t('.title') - - content_for :actionbar do - if FoodsoftConfig[:charge_members_manually] = link_to t('.close_all_direct_with_invoice'), close_all_direct_with_invoice_finance_order_index_path, method: :post, class: 'btn' diff --git a/app/views/group_order_invoices/_links.html.haml b/app/views/group_order_invoices/_links.html.haml new file mode 100644 index 00000000..9e55cedf --- /dev/null +++ b/app/views/group_order_invoices/_links.html.haml @@ -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' diff --git a/app/views/group_order_invoices/create.js.erb b/app/views/group_order_invoices/create.js.erb new file mode 100644 index 00000000..5a43e85d --- /dev/null +++ b/app/views/group_order_invoices/create.js.erb @@ -0,0 +1 @@ +$("#generate-invoice<%= params[:id] %>").html("<%= escape_javascript(render partial: 'links', locals: {order: @order}) %>"); diff --git a/app/views/group_order_invoices/create_multiple.js.erb b/app/views/group_order_invoices/create_multiple.js.erb new file mode 100644 index 00000000..11ebbe45 --- /dev/null +++ b/app/views/group_order_invoices/create_multiple.js.erb @@ -0,0 +1 @@ +$("#generate-invoice<%= @order.id %>").html("<%= escape_javascript(render partial: 'links', locals: {order: @order}) %>"); diff --git a/app/views/group_order_invoices/destroy.js.erb b/app/views/group_order_invoices/destroy.js.erb new file mode 100644 index 00000000..30ce5985 --- /dev/null +++ b/app/views/group_order_invoices/destroy.js.erb @@ -0,0 +1 @@ +$("#generate-invoice<%= @order.id %>").html("<%= escape_javascript(render partial: 'links', locals: {order: @order}) %>"); \ No newline at end of file diff --git a/app/views/group_orders/_form.html.haml b/app/views/group_orders/_form.html.haml index 3ffd583e..0cd27c76 100644 --- a/app/views/group_orders/_form.html.haml +++ b/app/views/group_orders/_form.html.haml @@ -69,7 +69,7 @@ = f.hidden_field :order_id = f.hidden_field :updated_by_user_id = f.hidden_field :ordergroup_id - %table.table.table-hover + %table.table %thead %tr %th= heading_helper Article, :name @@ -94,7 +94,7 @@ %i.icon-tag %td{colspan: "9"} - order_articles.each do |order_article| - %tr{class: "#{cycle('even', 'odd', name: 'articles')} order-article #{get_missing_units_css_class(@ordering_data[:order_articles][order_article.id][:missing_units])}", valign: "top"} + %tr{class: "#{cycle('even', 'odd', name: 'articles')} order-article #{get_missing_units_css_class(@ordering_data[:order_articles][order_article.id][:missing_units])}", valign: "top", tabindex: "0"} %td.name= order_article.article.name - if @order.stockit? %td= truncate order_article.article.supplier.name, length: 15 diff --git a/app/views/mailer/group_order_invoice.text.haml b/app/views/mailer/group_order_invoice.text.haml new file mode 100644 index 00000000..75948fbe --- /dev/null +++ b/app/views/mailer/group_order_invoice.text.haml @@ -0,0 +1 @@ += raw t '.text', group: @group.name, supplier: @supplier , foodcoop: FoodsoftConfig[:name] diff --git a/app/views/ordergroups/edit.html.haml b/app/views/ordergroups/edit.html.haml index 1cba43e6..9e964c89 100644 --- a/app/views/ordergroups/edit.html.haml +++ b/app/views/ordergroups/edit.html.haml @@ -57,6 +57,10 @@ = f.label :contact_person %br/ = f.text_field :contact_person + %p + = f.label :customer_number + %br/ + = f.text_field :customer_number %p = f.label :contact_phone %br/ diff --git a/app/views/shared/_group.html.haml b/app/views/shared/_group.html.haml index 3386aaab..c4d00679 100644 --- a/app/views/shared/_group.html.haml +++ b/app/views/shared/_group.html.haml @@ -6,6 +6,8 @@ %dd=h group.contact %dt= heading_helper(Ordergroup, :contact_address) + ':' %dd= link_to_gmaps group.contact_address + %dt= heading_helper(Ordergroup, :customer_number) + ':' + %dd=h group.customer_number - if group.break_start? or group.break_end? %dt= heading_helper(Ordergroup, :break) + ':' %dd= raw t '.break', start: format_date(group.break_start), end: format_date(group.break_end) diff --git a/config/locales/de.yml b/config/locales/de.yml index 6a957ec2..960ddf53 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -90,6 +90,17 @@ de: tolerance: Toleranz total_price: Summe unit_price: Preis/Einheit + group_order_invoice: + name: Bestellgruppenrechnung + links: + delete: Rechnung löschen + download: Rechnung herunterladen + generate: Rechnung erzeugen + invoice_date: Datum der Bestellgruppenrechnung + generate_with_date: setzen & erzeugen + download_all_zip: Alle Rechnungen herunterladen (zip) + payment_method: Guthaben + tax_number_not_set: Steuernummer in den Einstellungen nicht gesetzt invoice: amount: Betrag attachment: Anhang @@ -158,6 +169,7 @@ de: contact_address: Adresse contact_person: Kontaktperson contact_phone: Telefon + customer_number: Kundennummer description: Beschreibung ignore_apple_restriction: Bestellstop bei zu wenig Äpfeln ignorieren last_order: Zuletzt bestellt @@ -318,6 +330,7 @@ de: emails_title: E-Mails versenden tab_payment: schedule_title: Bestellschema + group_order_invoices: Bestellgruppenrechnungen tab_security: default_roles_title: Zugriff auf default_roles_paragraph: Jedes Mitglied der Foodcoop hat automatisch Zugriff auf folgende Bereiche. @@ -603,6 +616,10 @@ de: email_from: E-Mails werden so aussehen, als ob sie von dieser Adresse gesendet wurden. Kann leer gelassen werden, um die Kontaktadresse der Foodcoop zu benutzen. email_replyto: Setze diese Adresse, wenn Du Antworten auf Foodsoft E-Mails auf eine andere, als die oben angegebene Absenderadresse bekommen möchtest. email_sender: E-Mails werden so aussehen, als ob sie von dieser Adresse versendet wurden. Um zu vermeiden, dass E-Mails dadurch als Spam eingeordnet werden, muss der Webserver möglicherweise im SPF Eintrag der Domain der E-Mail Adresse eingetragen werden. + group_order_invoices: + use_automativ_go_invoices: Es werden auf die Bestellgruppen zugeschnittene Rechnungen für die jeweilige Bestellung beim Klicken auf "abrechnen" an alle Bestellgruppenmitglieder per Mail versendet. + payment_method: Zahlungsart wird auf der Bestellgruppenrechnung deklariert + vat_exempt: Eine Auflistung der Rechnungsartikel erfolgt ohne explizite Ausweisung der MwSt. und die Rechnung erhält den notwendigen Zusatz bzgl. der Kleinunternehmerregelung §19 (FoodCoop Marge ebenfalls nicht in Rechnung enthalten) help_url: Link zur Dokumentationsseite homepage: Webseite der Foodcoop ignore_browser_locale: Ignoriere die Sprache des Computers des Anwenders, wenn der Anwender noch keine Sprache gewählt hat. @@ -645,6 +662,7 @@ de: phone: Telefon street: Straße zip_code: Postleitzahl + tax_number: Steuernummer currency_space: Leerzeichen hinzufügen currency_unit: Währung custom_css: Angepasstes CSS @@ -660,6 +678,11 @@ de: email_from: Absenderadresse email_replyto: Antwortadresse email_sender: Senderadresse + group_order_invoices: + use_automatic_invoices: Automatisch bei Abrechnung per Mail versenden + separate_deposits: Pfand getrennt abrechnen + payment_method: Zahlungsart + vat_exempt: Diese Foodcoop ist MwSt. befreit help_url: URL Dokumentation homepage: Webseite ignore_browser_locale: Browsersprache ignorieren @@ -746,6 +769,47 @@ de: update: notice: Lieferung wurde aktualisiert. documents: + group_order_invoice_pdf: + filename: Rechnung%{number} + invoicer: Rechnungsteller*in + invoicee: Rechnungsempfänger*in + invoice_date: 'Rechnungsdatum: %{invoice_date}' + invoice_number: 'Rechnungsnummer: %{invoice_number}' + markup_included: zzgl. Foodcoop Marge auf brutto Preis %{marge}% + ordergroup: + contact_phone: 'Telefonnummer: %{contact_phone}' + contact_address: 'Adresse : %{contact_address}' + customer_number: 'Kundennummer: %{customer_number}' + name: Bestellgruppe %{ordergroup} + payment_method: 'Zahlungsart: %{payment_method}' + sum_to_pay: Zu zahlen gesamt + sum_to_pay_net: Zu zahlen gesamt (netto) + sum_to_pay_gross: Gesamt + small_business_regulation: Als Kleinunternehmer*in im Sinne von §19 Abs. 1 Umsatzsteuergesetz (UStG) wird keine Umsatzsteuer berechnet. + table_headline: 'Für die Bestellung fallen folgende Posten an:' + tax_excluded: exkl. MwSt. + tax_included: zzgl. Gesamtsumme MwSt. %{tax}% + tax_number: 'Steuernummer: %{number}' + title: Rechnung für die Bestellung bei %{supplier} + vat_exempt_rows: + - Name + - Anzahl + - Einzelpreis + - Artikel Gesamtpreis + no_price_markup_rows: + - Name + - Anzahl + - Einzelpreis (netto) + - Artikel Gesamtpreis (netto) + - MwSt. + - Artikel Gesamtpreis (brutto) + price_markup_rows: + - Name + - Anzahl + - Einzelpreis (netto) + - Artikel Gesamtpreis (netto) + - MwSt. + - Artikel Gesamtpreis (brutto) inkl. Foodcoopmarge %{marge}% order_by_articles: filename: Bestellung %{name}-%{date} - Artikelsortierung title: 'Artikelsortierung der Bestellung: %{name}, beendet am %{date}' @@ -769,6 +833,7 @@ de: heading: Artikelübersicht (%{count}) title: 'Sortiermatrix der Bestellung: %{name}, beendet am %{date}' errors: + check_tax_number: Überprüft, ob die Steuernummer der Foodcoop richtig gesetzt ist general: Ein Problem ist aufgetreten. general_again: Ein Fehler ist aufgetreten. Bitte erneut versuchen. general_msg: 'Ein Fehler ist aufgetreten: %{msg}' @@ -792,6 +857,8 @@ de: close: alert: 'Ein Fehler ist beim Abrechnen aufgetreten: %{message}' notice: Bestellung wurde erfolgreich abgerechnet, die Kontostände aktualisiert. + notice_mail: Bestellung wurde erfolgreich abgerechnet, die Kontostände aktualisiert. Außerdem wurden automatisch Rechnungen an die Bestellgruppenmitglieder geschickt. + settings_not_set: Keine Emails mit Bestellgruppenrechnungen versendet. Bitte überprüfe die Einstellungen. Steuernummer gesetzt? close_all_direct_with_invoice: notice: 'Es wurden %{count} Bestellung abgerechnet.' close_direct: @@ -856,11 +923,15 @@ de: ended: beendet name: Lieferantin no_closed_orders: Derzeit gibt es keine beendeten Bestellungen. + state: Status summary: changed: Daten wurden verändert! duration: von %{starts} bis %{ends} fc_amount: 'FC-Betrag:' + deposit: 'Pfand netto:' + gross_deposit: 'Pfand brutto:' + fc_deposit: 'Pfand FC-Betrag:' fc_profit: FC Gewinn gross_amount: 'Bruttobetrag:' groups_amount: 'Gruppenbeträge:' @@ -1273,6 +1344,15 @@ de: header: "%{user} schrieb am %{date}:" subject: Feedback zur Foodsoft from_via_foodsoft: "%{name} via Foodsoft" + group_order_invoice: + subject: Bestellgruppenrechnung für %{group} bei %{supplier} + text: | + Liebe Bestellgruppe %{group}, + + Die Sammelbestellung bei %{supplier} wurde soeben abgerechnet und für die jeweiligen Bestellgruppen Rechnungen angelegt. + Im Anhang befindet sich daher eure Rechnung. + + Viele Grüße von %{foodcoop} invite: subject: Einladung in die Foodcoop text: | @@ -1508,6 +1588,7 @@ de: orders_finished: Beendet orders_open: Laufend orders_settled: Abgerechnet + not_closed: Bestellung noch nicht abgerechnet title: Bestellungen verwalten model: close_direct_message: Die Bestellung wurde abgechlossen, ohne die Mitgliederkonten zu belasten. @@ -1907,3 +1988,4 @@ de: time: formats: foodsoft_datetime: "%d.%m.%Y %H:%M" + file: "%Y-%d-%B" \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index b4f41c5c..1ee30987 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -90,6 +90,18 @@ en: tolerance: Tolerance total_price: Sum unit_price: Price/Unit + group_order_invoice: + name: Group order invoice + links: + delete: delete invoice + download: download invoice + invoice_date: date of group order invoice + generate: generate invoice + generate_with_date: set & generate + download_all_zip: download all invoices as zip + + payment_method: Credit + tax_number_not_set: Tax number not set in configs invoice: amount: Amount attachment: Attachment @@ -158,6 +170,7 @@ en: contact_address: Address contact_person: Contact person contact_phone: Phone + customer_number: Customer number description: Description ignore_apple_restriction: Ignore order stop by apple points restriction last_order: Last order @@ -318,6 +331,7 @@ en: emails_title: Sending email tab_payment: schedule_title: Ordering schedule + group_order_invoices: Group order invoices tab_security: default_roles_title: Access to default_roles_paragraph: By default every member of the foodcoop has access to the following areas. @@ -603,6 +617,9 @@ en: email_from: Emails will appear to be from this email address. Leave empty to use the foodcoop's contact address. email_replyto: Set this when you want to receive replies from emails sent by Foodsoft on a different address than the above. email_sender: Emails will appear to be sent from this email address. To avoid emails sent being classified as spam, the webserver may need to be registered in the SPF record of the email address's domain. + use_automatic_invoices: A listing of the invoice items is made without explicit display of VAT and the invoice receives the necessary addition regarding the small business regulation §19 (applies to Germany) + payment_method: Payment type is declared on the order group invoice + vat_exempt: A listing of the invoice items is made without explicit display of VAT and the invoice contains the necessary addition regarding the German Kleinunternehmerregelung §19 UStG (attention! FoodCoop marge not included in nvoice). help_url: Documentation website. homepage: Website of your foodcoop. ignore_browser_locale: Ignore the language of user's computer when the user has not chosen a language yet. @@ -630,6 +647,8 @@ en: tolerance_is_costly: Order as much of the member tolerance as possible (compared to only as much needed to fill the last box). Enabling this also includes the tolerance in the total price of the open member order. distribution_strategy: How articles should be distributed after an order has been received. use_apple_points: When the apple point system is enabled, members are required to do some tasks to be able to keep ordering. + use_automatic_invoices: When an order is settled, invoices for the individual order groups are automatically sent by mail + payment_method: Payment Method for group order invoices use_boxfill: When enabled, near end of an order, members are only able to change their order when increases the total amount ordered. This helps to fill any remaining boxes. You still need to set a box-fill date for the orders. use_iban: When enabled, supplier and user provide an additonal field for storing the international bank account number. use_nick: Show and use nicknames instead of real names. When enabling this, please check that each user has a nickname. @@ -645,6 +664,7 @@ en: phone: Phone street: Street zip_code: Postcode + tax_number: Tax number currency_space: add space currency_unit: Currency custom_css: Custom CSS @@ -689,6 +709,11 @@ en: first_order_first_serve: First distribute to those who ordered first no_automatic_distribution: No automatic distribution use_apple_points: Apple points + group_order_invoices: + use_automatic_invoices: Send automatically via mail after oder settlement + payment_method: Payment method + separate_deposits: Separate deposits on invoice + vat_exempt: This foodcoopis VAT exempt use_boxfill: Box-fill phase use_iban: Use IBAN use_nick: Use nicknames @@ -746,6 +771,47 @@ en: update: notice: Delivery was updated. documents: + group_order_invoice_pdf: + ordergroup: + contact_phone: 'Phone: %{contact_phone}' + contact_address: 'Adress : %{contact_address}' + customer_number: 'Customer number: %{customer_number}' + name: 'Ordergroup: %{ordergroup}' + filename: Invoice%{number} + invoicee: Invoicee + invoicer: Invoicer + invoice_date: 'Invoice date: %{invoice_date}' + invoice_number: 'Invoice number: %{invoice_number}' + markup_included: incl Foodcoop Marge on gross price %{marge}% + payment_method: 'Payment_method: %{payment_method}' + small_business_regulation: As a small entrepreneur in the sense of §19 para. 1 of the Umsatzsteuergesetz (UStG), no value added tax is charged. + sum_to_pay: Total sum + sum_to_pay_net: Total sum (net) + sum_to_pay_gross: Total sum (gross) + table_headline: 'The following items will be charged for the order:' + tax_excluded: excl. MwSt. + tax_included: incl. VAT %{tax}% + tax_number: 'Tax number: %{number}' + title: Invoice for order at %{supplier} + vat_exempt_rows: + - Name + - Quantity + - Unit price + - Total price + no_price_markup_rows: + - Name + - Quantity + - Unit price (net) + - Total price (net) + - VAT + - Total price (gross) + price_markup_rows: + - Name + - Quantity + - Unit price (net) + - Total price (net) + - VAT + - Total price (gross) incl. foodcoop margin order_by_articles: filename: Order %{name}-%{date} - by articles title: 'Order sorted by articles: %{name}, closed at %{date}' @@ -769,6 +835,7 @@ en: heading: Article overview (%{count}) title: 'Order sorting matrix: %{name}, closed at %{date}' errors: + check_tax_number: Please check whether the foodcoop's tax number is set correctly. general: A problem has occured. general_again: A problem has occured. Please try again. general_msg: 'A problem has occured: %{msg}' @@ -792,6 +859,7 @@ en: close: alert: 'An error occured while accounting: %{message}' notice: Order was settled succesfully, the balance of the account was updated. + settings_not_set: No emails with order group invoices sent. Please check the settings. Tax number set? close_all_direct_with_invoice: notice: '%{count} orders have been settled.' close_direct: @@ -1272,6 +1340,15 @@ en: feedback: header: "%{user} wrote at %{date}:" subject: Feedback for Foodsoft + group_order_invoice: + subject: Order group invoice for %{group} at %{supplier} + text: | + Dear order group %{group}, + + The collective order at %{supplier} has just been settled and invoices have been created for the respective order groups. + Attached you will find your invoice. + + Best regards from %{foodcoop} from_via_foodsoft: "%{name} via Foodsoft" invite: subject: Invitation to the Foodcoop @@ -1511,6 +1588,7 @@ en: orders_finished: Closed orders_open: Open orders_settled: Settled + not_closed: Order not yet settled title: Manage orders model: close_direct_message: Order settled without charging member accounts. @@ -1910,3 +1988,4 @@ en: time: formats: foodsoft_datetime: "%Y-%m-%d %H:%M" + file: "%Y-%d-%B" diff --git a/config/routes.rb b/config/routes.rb index 8fea34b0..de68ce73 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -143,6 +143,13 @@ Rails.application.routes.draw do end end + + 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 + resources :article_categories ########### Finance diff --git a/db/migrate/20211208142719_create_group_order_invoices.rb b/db/migrate/20211208142719_create_group_order_invoices.rb new file mode 100644 index 00000000..b0aa13f7 --- /dev/null +++ b/db/migrate/20211208142719_create_group_order_invoices.rb @@ -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 diff --git a/db/migrate/20230822120005_add_customer_number_to_group.rb b/db/migrate/20230822120005_add_customer_number_to_group.rb new file mode 100644 index 00000000..9b4c2278 --- /dev/null +++ b/db/migrate/20230822120005_add_customer_number_to_group.rb @@ -0,0 +1,5 @@ +class AddCustomerNumberToGroup < ActiveRecord::Migration[7.0] + def change + add_column :groups, :customer_number, :string, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 4c853039..e024426f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,8 +10,8 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do - create_table "action_text_rich_texts", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| +ActiveRecord::Schema[7.0].define(version: 2023_08_22_120005) do + create_table "action_text_rich_texts", charset: "utf8mb4", force: :cascade do |t| t.string "name", null: false t.text "body", size: :long t.string "record_type", null: false @@ -21,7 +21,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true end - create_table "active_storage_attachments", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "active_storage_attachments", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false t.bigint "record_id", null: false @@ -31,7 +31,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end - create_table "active_storage_blobs", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "active_storage_blobs", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.string "key", null: false t.string "filename", null: false t.string "content_type" @@ -43,19 +43,19 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end - create_table "active_storage_variant_records", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "active_storage_variant_records", charset: "utf8mb4", force: :cascade do |t| t.integer "blob_id", null: false t.string "variation_digest", null: false t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end - create_table "article_categories", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "article_categories", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.string "name", default: "", null: false t.string "description" t.index ["name"], name: "index_article_categories_on_name", unique: true end - create_table "article_prices", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "article_prices", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "article_id", null: false t.decimal "price", precision: 8, scale: 2, default: "0.0", null: false t.decimal "tax", precision: 8, scale: 2, default: "0.0", null: false @@ -65,7 +65,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["article_id"], name: "index_article_prices_on_article_id" end - create_table "articles", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "articles", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.string "name", default: "", null: false t.integer "supplier_id", default: 0, null: false t.integer "article_category_id", default: 0, null: false @@ -91,14 +91,14 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["type"], name: "index_articles_on_type" end - create_table "assignments", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "assignments", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "user_id", default: 0, null: false t.integer "task_id", default: 0, null: false t.boolean "accepted", default: false t.index ["user_id", "task_id"], name: "index_assignments_on_user_id_and_task_id", unique: true end - create_table "bank_accounts", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "bank_accounts", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.string "name", null: false t.string "iban" t.string "description" @@ -108,14 +108,14 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.integer "bank_gateway_id" end - create_table "bank_gateways", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "bank_gateways", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.string "name", null: false t.string "url", null: false t.string "authorization" t.integer "unattended_user_id" end - create_table "bank_transactions", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "bank_transactions", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "bank_account_id", null: false t.string "external_id" t.date "date" @@ -129,7 +129,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["financial_link_id"], name: "index_bank_transactions_on_financial_link_id" end - create_table "documents", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "documents", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.string "name" t.string "mime" t.binary "data", size: :long @@ -140,16 +140,16 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["parent_id"], name: "index_documents_on_parent_id" end - create_table "financial_links", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "financial_links", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.text "note" end - create_table "financial_transaction_classes", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "financial_transaction_classes", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.string "name", null: false t.boolean "ignore_for_account_balance", default: false, null: false end - create_table "financial_transaction_types", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "financial_transaction_types", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.string "name", null: false t.integer "financial_transaction_class_id", null: false t.string "name_short" @@ -157,7 +157,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["name_short"], name: "index_financial_transaction_types_on_name_short" end - create_table "financial_transactions", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "financial_transactions", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "ordergroup_id" t.decimal "amount", precision: 8, scale: 2, default: "0.0", null: false t.text "note", null: false @@ -171,7 +171,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["reverts_id"], name: "index_financial_transactions_on_reverts_id", unique: true end - create_table "group_order_article_quantities", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "group_order_article_quantities", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "group_order_article_id", default: 0, null: false t.integer "quantity", default: 0 t.integer "tolerance", default: 0 @@ -179,7 +179,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["group_order_article_id"], name: "index_group_order_article_quantities_on_group_order_article_id" end - create_table "group_order_articles", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "group_order_articles", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "group_order_id", default: 0, null: false t.integer "order_article_id", default: 0, null: false t.integer "quantity", default: 0, null: false @@ -192,7 +192,17 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["order_article_id"], name: "index_group_order_articles_on_order_article_id" end - create_table "group_orders", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "group_order_invoices", charset: "utf8mb4", force: :cascade do |t| + t.integer "group_order_id" + t.bigint "invoice_number" + t.date "invoice_date" + t.string "payment_method" + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.index ["group_order_id"], name: "index_group_order_invoices_on_group_order_id", unique: true + end + + create_table "group_orders", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "ordergroup_id" t.integer "order_id", default: 0, null: false t.decimal "price", precision: 8, scale: 2, default: "0.0", null: false @@ -205,7 +215,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["ordergroup_id"], name: "index_group_orders_on_ordergroup_id" end - create_table "groups", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "groups", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.string "type", default: "", null: false t.string "name", default: "", null: false t.string "description" @@ -227,10 +237,11 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.date "break_end" t.boolean "role_invoices", default: false, null: false t.boolean "role_pickups", default: false, null: false + t.string "customer_number" t.index ["name"], name: "index_groups_on_name", unique: true end - create_table "invites", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "invites", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.string "token", default: "", null: false t.datetime "expires_at", precision: nil, null: false t.integer "group_id", default: 0, null: false @@ -239,7 +250,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["token"], name: "index_invites_on_token" end - create_table "invoices", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "invoices", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "supplier_id" t.string "number" t.date "date" @@ -257,7 +268,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["supplier_id"], name: "index_invoices_on_supplier_id" end - create_table "links", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "links", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.string "name", null: false t.string "url", null: false t.integer "workgroup_id" @@ -265,7 +276,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.string "authorization" end - create_table "mail_delivery_status", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "mail_delivery_status", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.datetime "created_at", precision: nil t.string "email", null: false t.string "message", null: false @@ -274,13 +285,13 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["email"], name: "index_mail_delivery_status_on_email" end - create_table "memberships", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "memberships", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "group_id", default: 0, null: false t.integer "user_id", default: 0, null: false t.index ["user_id", "group_id"], name: "index_memberships_on_user_id_and_group_id", unique: true end - create_table "message_recipients", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "message_recipients", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "message_id", null: false t.integer "user_id", null: false t.integer "email_state", default: 0, null: false @@ -289,7 +300,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["user_id", "read_at"], name: "index_message_recipients_on_user_id_and_read_at" end - create_table "messages", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "messages", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "sender_id" t.string "subject", null: false t.boolean "private", default: false @@ -300,7 +311,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.binary "received_email", size: :medium end - create_table "oauth_access_grants", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "oauth_access_grants", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "resource_owner_id", null: false t.integer "application_id", null: false t.string "token", null: false @@ -312,7 +323,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true end - create_table "oauth_access_tokens", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "oauth_access_tokens", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "resource_owner_id" t.integer "application_id" t.string "token", null: false @@ -326,7 +337,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["token"], name: "index_oauth_access_tokens_on_token", unique: true end - create_table "oauth_applications", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "oauth_applications", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.string "name", null: false t.string "uid", null: false t.string "secret", null: false @@ -338,7 +349,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true end - create_table "order_articles", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "order_articles", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "order_id", default: 0, null: false t.integer "article_id", default: 0, null: false t.integer "quantity", default: 0, null: false @@ -352,7 +363,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["order_id"], name: "index_order_articles_on_order_id" end - create_table "order_comments", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "order_comments", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "order_id" t.integer "user_id" t.text "text" @@ -360,7 +371,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["order_id"], name: "index_order_comments_on_order_id" end - create_table "orders", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "orders", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "supplier_id" t.text "note" t.datetime "starts", precision: nil @@ -379,7 +390,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["state"], name: "index_orders_on_state" end - create_table "page_versions", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "page_versions", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "page_id" t.integer "lock_version" t.text "body" @@ -390,7 +401,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["page_id"], name: "index_page_versions_on_page_id" end - create_table "pages", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "pages", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.string "title" t.text "body" t.string "permalink" @@ -404,20 +415,20 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["title"], name: "index_pages_on_title" end - create_table "periodic_task_groups", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "periodic_task_groups", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.date "next_task_date" t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false end - create_table "poll_choices", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "poll_choices", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "poll_vote_id", null: false t.integer "choice", null: false t.integer "value", null: false t.index ["poll_vote_id", "choice"], name: "index_poll_choices_on_poll_vote_id_and_choice", unique: true end - create_table "poll_votes", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "poll_votes", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "poll_id", null: false t.integer "user_id", null: false t.integer "ordergroup_id" @@ -427,7 +438,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["poll_id", "user_id", "ordergroup_id"], name: "index_poll_votes_on_poll_id_and_user_id_and_ordergroup_id", unique: true end - create_table "polls", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "polls", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "created_by_user_id", null: false t.string "name", null: false t.text "description" @@ -447,7 +458,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["final_choice"], name: "index_polls_on_final_choice" end - create_table "printer_job_updates", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "printer_job_updates", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "printer_job_id", null: false t.datetime "created_at", precision: nil, null: false t.string "state", null: false @@ -455,7 +466,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["printer_job_id", "created_at"], name: "index_printer_job_updates_on_printer_job_id_and_created_at" end - create_table "printer_jobs", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "printer_jobs", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "order_id" t.string "document", null: false t.integer "created_by_user_id", null: false @@ -464,7 +475,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["finished_at"], name: "index_printer_jobs_on_finished_at" end - create_table "settings", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "settings", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.string "var", null: false t.text "value" t.integer "thing_id" @@ -474,7 +485,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["thing_type", "thing_id", "var"], name: "index_settings_on_thing_type_and_thing_id_and_var", unique: true end - create_table "stock_changes", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "stock_changes", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "stock_event_id" t.integer "order_id" t.integer "stock_article_id" @@ -484,7 +495,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["stock_event_id"], name: "index_stock_changes_on_stock_event_id" end - create_table "stock_events", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "stock_events", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.integer "supplier_id" t.date "date" t.datetime "created_at", precision: nil @@ -494,14 +505,14 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["supplier_id"], name: "index_stock_events_on_supplier_id" end - create_table "supplier_categories", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "supplier_categories", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.string "name", null: false t.string "description" t.integer "financial_transaction_class_id" t.integer "bank_account_id" end - create_table "suppliers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "suppliers", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.string "name", default: "", null: false t.string "address", default: "", null: false t.string "phone", default: "", null: false @@ -523,7 +534,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["name"], name: "index_suppliers_on_name", unique: true end - create_table "tasks", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "tasks", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.string "name", default: "", null: false t.text "description" t.date "due_date" @@ -540,7 +551,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do t.index ["workgroup_id"], name: "index_tasks_on_workgroup_id" end - create_table "users", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + create_table "users", id: :integer, charset: "utf8mb4", force: :cascade do |t| t.string "nick" t.string "password_hash", default: "", null: false t.string "password_salt", default: "", null: false diff --git a/spec/factories/group_order_invoice.rb b/spec/factories/group_order_invoice.rb new file mode 100644 index 00000000..89723873 --- /dev/null +++ b/spec/factories/group_order_invoice.rb @@ -0,0 +1,7 @@ +require 'factory_bot' + +FactoryBot.define do + factory :group_order_invoice do + group_order { create :group_order } + end +end diff --git a/spec/integration/group_order_invoices_spec.rb b/spec/integration/group_order_invoices_spec.rb new file mode 100644 index 00000000..f6ece77d --- /dev/null +++ b/spec/integration/group_order_invoices_spec.rb @@ -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 diff --git a/spec/models/group_order_invoice_spec.rb b/spec/models/group_order_invoice_spec.rb new file mode 100644 index 00000000..24bfcf7e --- /dev/null +++ b/spec/models/group_order_invoice_spec.rb @@ -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