diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 95fff8c9..5baec4fa 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -43,6 +43,7 @@ #= require accounting_posts #= require reports_orders #= require reports_invoices +#= require reports_billing #= require expenses #= require expense_reviews #= require meal_compensations diff --git a/app/assets/javascripts/reports_billing.js.coffee b/app/assets/javascripts/reports_billing.js.coffee new file mode 100644 index 00000000..f990e6e6 --- /dev/null +++ b/app/assets/javascripts/reports_billing.js.coffee @@ -0,0 +1,24 @@ +# Copyright (c) 2006-2017, Puzzle ITC GmbH. This file is part of +# PuzzleTime and licensed under the Affero General Public License version 3 +# or later. See the COPYING file at the top-level directory or at +# https://github.com/puzzle/puzzletime. + + +app = window.App ||= {} + +app.reportsBilling = new class + init: -> + @dateFilterChanged() + + dateFilterChanged: -> + $('.billing_reports form[role="filter"]').find('#start_date,#end_date') + .datepicker('option', 'disabled', $('#period_shortcut').val()) + if $('#period_shortcut').val() + $('.billing_reports form[role="filter"]').find('#start_date,#end_date').val("") + +$(document).on('ajax:success', '.billing_reports form[role="filter"]', -> + app.reportsBilling.init() +) + +$(document).on 'turbolinks:load', -> + app.reportsBilling.init() diff --git a/app/controllers/billing_reports_controller.rb b/app/controllers/billing_reports_controller.rb new file mode 100644 index 00000000..363c737b --- /dev/null +++ b/app/controllers/billing_reports_controller.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# Copyright (c) 2006-2017, Puzzle ITC GmbH. This file is part of +# PuzzleTime and licensed under the Affero General Public License version 3 +# or later. See the COPYING file at the top-level directory or at +# https://github.com/puzzle/puzzletime. + +class BillingReportsController < ApplicationController + include DryCrud::Rememberable + include WithPeriod + + self.remember_params = %w[start_date end_date period_shortcut department_id + client_work_item_id category_work_item_id kind_id + status_id responsible_id] + + before_action :authorize_class + + def index + respond_to do |format| + set_period + @report = Billing::Report.new(@period, params) + format.html do + set_filter_values + unless @report.filters_defined? + params[:department_id] ||= @user.department_id + params[:responsible_id] ||= @user.id + params.reverse_merge!(period_shortcut: '0m') + set_period + @report = Billing::Report.new(@period, params) + end + end + format.js do + set_filter_values + end + end + end + + private + + def set_filter_values + @departments = Department.list + @clients = WorkItem.joins(:client).list + @categories = [] + @categories = WorkItem.find(params[:client_work_item_id]).categories.list if params[:client_work_item_id].present? + @order_kinds = OrderKind.list + @order_status = OrderStatus.list + @order_responsibles = Employee.joins(:managed_orders).distinct.list + @target_scopes = TargetScope.list + end + + def authorize_class + authorize!(:reports, Order) + end +end diff --git a/app/domain/billing/report.rb b/app/domain/billing/report.rb new file mode 100644 index 00000000..88fcf558 --- /dev/null +++ b/app/domain/billing/report.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +# Copyright (c) 2006-2017, Puzzle ITC GmbH. This file is part of +# PuzzleTime and licensed under the Affero General Public License version 3 +# or later. See the COPYING file at the top-level directory or at +# https://github.com/puzzle/puzzletime. + +module Billing + class Report + include Filterable + + attr_reader :period, :params + + def initialize(period, params = {}) + @period = period + @params = params + end + + def page(&) + section = entries[((current_page - 1) * limit_value)...(current_page * limit_value)] + ([total] + section).each(&) if section.present? + end + + def entries + @entries ||= sort_entries(load_entries) + end + + def total + @total ||= Billing::Report::Total.new(self) + end + + def current_page + (params[:page] || 1).to_i + end + + def total_pages + (entries.size / limit_value.to_f).ceil + end + + def limit_value + 20 + end + + delegate :present?, to: :entries + + def filters_defined? + filters = params.except(:action, :controller, :format, :utf8, :page, + :clear) + filters.present? && filters.values.any?(&:present?) + end + + private + + def load_entries + orders = load_orders.to_a + worktimes = load_worktimes(orders) + accounting_posts = accounting_posts_to_hash(load_accounting_posts(orders)) + hours = hours_to_hash(load_accounting_post_hours(accounting_posts.values)) + invoices = invoices_to_hash(load_invoices(orders)) + entries = orders.filter_map { |o| build_entry(o, worktimes, accounting_posts, hours, invoices) } + entries.filter { |e| e.not_billed_hours.positive? } # Only show if there are unbilled HOURS + end + + # prepare worktimes, as some columns take data directly from worktimes + def load_worktimes(orders) + Worktime.in_period(@period) + .joins(:work_item) + .joins('INNER JOIN accounting_posts ON accounting_posts.work_item_id = work_items.id') + .joins('INNER JOIN orders ON orders.work_item_id = ANY (work_items.path_ids)') + .where(orders: { id: orders.collect(&:id) }) + .where(billable: true) + .select('orders.id AS order_id, SUM(worktimes.hours) AS hours, (worktimes.invoice_id IS NOT NULL) AS has_invoice, SUM(worktimes.hours * accounting_posts.offered_rate) AS amount') + .group('order_id, has_invoice') + .group_by { |time| time['has_invoice'].present? } + .transform_values { |partition| partition.index_by(&:order_id) } + end + + def load_orders + entries = Order.list.includes(:status, :targets, :order_uncertainties) + entries = filter_by_parent(entries) + filter_entries_by(entries, :kind_id, :responsible_id, :status_id, :department_id) + end + + def load_accounting_posts(orders) + AccountingPost.joins(:work_item) + .joins('INNER JOIN orders ON orders.work_item_id = ANY (work_items.path_ids)') + .where(orders: { id: orders.collect(&:id) }) + .pluck('orders.id, accounting_posts.id, accounting_posts.offered_total, ' \ + 'accounting_posts.offered_rate, accounting_posts.offered_hours') + end + + def accounting_posts_to_hash(result) + result.each_with_object(Hash.new { |h, k| h[k] = {} }) do |row, hash| + hash[row.first][row[1]] = { offered_total: row[2], + offered_rate: row[3], + offered_hours: row[4] } + end + end + + def load_accounting_post_hours(accounting_posts) + accounting_post_hours = + Worktime + .joins(:work_item) + .joins('INNER JOIN accounting_posts ON ' \ + 'accounting_posts.work_item_id = ANY (work_items.path_ids)') + .where(accounting_posts: { id: accounting_posts.collect(&:keys).flatten }) + + accounting_post_hours = accounting_post_hours.in_period(period) if params[:status_preselection].blank? || params[:status_preselection] == 'not_closed' + + accounting_post_hours + .group('accounting_posts.id, worktimes.billable') + .pluck('accounting_posts.id, worktimes.billable, SUM(worktimes.hours)') + end + + def hours_to_hash(result) + result.each_with_object(Hash.new { |h, k| h[k] = Hash.new(0) }) do |row, hash| + hash[row.first][row.second] = row.last + end + end + + def load_invoices(orders) + invoices = Invoice.where(order_id: orders.collect(&:id)) + + invoices = invoices.where(period.where_condition('billing_date')) if params[:status_preselection].blank? || params[:status_preselection] == 'not_closed' + + invoices + .group('order_id') + .pluck('order_id, SUM(total_amount) AS total_amount, SUM(total_hours) AS total_hours') + end + + def invoices_to_hash(result) + result.each_with_object(Hash.new { |h, k| h[k] = Hash.new(0) }) do |row, hash| + hash[row.first][:total_amount] = row[1] + hash[row.first][:total_hours] = row[2] + end + end + + def build_entry(order, worktimes, accounting_posts, hours, invoices) + posts = accounting_posts[order.id] + post_hours = hours.slice(*posts.keys) + + Billing::Report::Entry.new(order, worktimes, posts, post_hours, invoices[order.id]) + end + + def filter_by_parent(orders) + if params[:category_work_item_id].present? + orders.where('? = ANY (work_items.path_ids)', params[:category_work_item_id]) + elsif params[:client_work_item_id].present? + orders.where('? = ANY (work_items.path_ids)', params[:client_work_item_id]) + else + orders + end + end + + def sort_entries(entries) + dir = params[:sort_dir].to_s.casecmp('desc').zero? ? -1 : 1 + if sort_by_string? + sort_by_string(entries, dir) + elsif sort_by_number? + sort_by_number(entries, dir) + else + entries + end + end + + def sort_by_string? + %w[client].include?(params[:sort]) + end + + def sort_by_number? + Billing::Report::Entry.public_instance_methods(false) + .collect(&:to_s) + .include?(params[:sort]) + end + + def sort_by_target? + params[:sort].to_s.match(/\Atarget_scope_(\d+)\z/) + end + + def sort_by_string(entries, dir) + sorted = entries.sort_by do |e| + e.send(params[:sort]) + end + sorted.reverse! if dir.positive? + sorted + end + + def sort_by_number(entries, dir) + entries.sort_by do |e| + e.send(params[:sort]).to_f * dir + end + end + + def sort_by_target(entries, target_scope_id, dir) + entries.sort_by do |e| + dir * OrderTarget::RATINGS.index(e.target(target_scope_id).try(:rating)).to_i + end + end + end +end diff --git a/app/domain/billing/report/entry.rb b/app/domain/billing/report/entry.rb new file mode 100644 index 00000000..e4e0e0c2 --- /dev/null +++ b/app/domain/billing/report/entry.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +# Copyright (c) 2006-2017, Puzzle ITC GmbH. This file is part of +# PuzzleTime and licensed under the Affero General Public License version 3 +# or later. See the COPYING file at the top-level directory or at +# https://github.com/puzzle/puzzletime. + +module Billing + class Report + class Entry < SimpleDelegator + attr_reader :order, :accounting_posts, :hours, :invoices + + def initialize(order, worktimes, accounting_posts, hours, invoices) + super(order) + @order = order + @worktimes = worktimes + @accounting_posts = accounting_posts + @hours = hours + @invoices = invoices + end + + def client + work_item.path_names.lines.to_a.first.strip + end + + def category + work_item.path_ids.size > 2 ? work_item.path_names.lines.to_a.second.strip : nil + end + + def offered_amount + @offered_amount ||= sum_accounting_posts { |id| post_value(id, :offered_total) } + end + + def offered_rate + @offered_rate ||= + if offered_hours.positive? + (offered_amount / offered_hours).to_d + else + rates = sum_accounting_posts { |id| post_value(id, :offered_rate) } + rates.positive? ? rates / accounting_posts.size : nil + end + end + + def supplied_amount + @supplied_amount ||= sum_accounting_posts { |id| post_value(id, :offered_rate) * post_hours(id) } + end + + def supplied_hours + @supplied_hours ||= sum_accounting_posts { |id| post_hours(id) } + end + + def billable_amount + @billable_amount ||= sum_accounting_posts { |id| post_value(id, :offered_rate) * post_hours(id, true) } + end + + def billable_hours + @billable_hours ||= sum_accounting_posts { |id| post_hours(id, true) } + end + + def billed_amount + return 0 unless @worktimes.present? && @worktimes[true].present? + + entry = @worktimes[true][@order.id] + entry.present? ? entry['amount'] || 0 : 0 + end + + def not_billed_hours + return 0 unless @worktimes.present? && @worktimes[false].present? + + entry = @worktimes[false][@order.id] + entry.present? ? entry['hours'] || 0 : 0 + end + + def not_billed_amount + return 0 unless @worktimes.present? && @worktimes[false].present? + + entry = @worktimes[false][@order.id] + entry.present? ? entry['amount'] || 0 : 0 + end + + def billed_hours + invoices[:total_hours].to_d + end + + def billability + @billability ||= supplied_hours.positive? ? (billable_hours / supplied_hours * 100).round : nil + end + + def billed_rate + @billed_rate ||= billable_hours.positive? ? billed_amount / billable_hours : nil + end + + def target(scope_id) + targets.find { |t| t.target_scope_id == scope_id.to_i } + end + + private + + def sum_accounting_posts(&) + accounting_posts.keys.sum(&) + end + + def post_hours(id, billable = nil) + h = hours[id] + return BigDecimal(0) unless h + + if billable.nil? + h.values.sum.to_d + else + h[billable].to_d + end + end + + def post_value(id, key) + accounting_posts[id][key] || 0 + end + + # caching these explicitly gives quite a performance benefit if many orders are exported + def targets + @targets ||= order.targets.to_a + end + end + end +end diff --git a/app/domain/billing/report/total.rb b/app/domain/billing/report/total.rb new file mode 100644 index 00000000..c707ae1b --- /dev/null +++ b/app/domain/billing/report/total.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# Copyright (c) 2006-2017, Puzzle ITC GmbH. This file is part of +# PuzzleTime and licensed under the Affero General Public License version 3 +# or later. See the COPYING file at the top-level directory or at +# https://github.com/puzzle/puzzletime. + +module Billing + class Report + class Total + delegate :entries, to: :@report + + def initialize(report) + @report = report + end + + def parent_names; end + + def to_s + "Total (#{entries.count})" + end + + def supplied_amount + entries.sum(&:supplied_amount) + end + + def billable_amount + entries.sum(&:billable_amount) + end + + def billed_amount + @billed_amount ||= entries.sum(&:billed_amount) + end + + def not_billed_amount + @not_billed_amount ||= entries.sum(&:not_billed_amount) + end + + def target(_id); end + end + end +end diff --git a/app/views/billing_reports/_filters.html.haml b/app/views/billing_reports/_filters.html.haml new file mode 100644 index 00000000..f71b2df7 --- /dev/null +++ b/app/views/billing_reports/_filters.html.haml @@ -0,0 +1,51 @@ +-# Copyright (c) 2006-2017, Puzzle ITC GmbH. This file is part of +-# PuzzleTime and licensed under the Affero General Public License version 3 +-# or later. See the COPYING file at the top-level directory or at +-# https://github.com/puzzle/puzzletime. + + += form_tag(nil, method: :get, class: 'form-inline', role: 'filter', remote: true, data: { spin: true }) do + = hidden_field_tag :page, 1 + + = direct_filter_date(:start_date, 'Zeiten von', @period.start_date) + = direct_filter_date(:end_date, 'bis', @period.end_date) + + = direct_filter_select(:period_shortcut, + nil, + params[:status_preselection] == 'closed' ? predefined_past_quarter_period_options : predefined_past_period_options, + prompt: 'benutzerdefiniert') + + = direct_filter_select(:department_id, + 'OE', + @departments, + class: 'searchable', + multiple: true) + + = direct_filter_select(:client_work_item_id, + 'Kunde', + @clients, + class: 'searchable', + style: 'width: 360px;', + data: { update: '#category_work_item_id', + url: categories_clients_path }) + + = direct_filter_select(:kind_id, + 'Auftragsart', + @order_kinds, + class: 'searchable', + multiple: true) + + = direct_filter_select(:status_id, + 'Status', + @order_status, + class: 'searchable', + multiple: true) + + = direct_filter_select(:responsible_id, + 'Verantwortlich', + @order_responsibles, + class: 'searchable', + multiple: true) + + .form-group + = spinner diff --git a/app/views/billing_reports/_list.html.haml b/app/views/billing_reports/_list.html.haml new file mode 100644 index 00000000..74f8d81e --- /dev/null +++ b/app/views/billing_reports/_list.html.haml @@ -0,0 +1,98 @@ +-# Copyright (c) 2006-2017, Puzzle ITC GmbH. This file is part of +-# PuzzleTime and licensed under the Affero General Public License version 3 +-# or later. See the COPYING file at the top-level directory or at +-# https://github.com/puzzle/puzzletime. + +- if @report.filters_defined? + - if @report.present? + .table Hinweis: Es werden nur Aufträge mit noch nicht verrechneten Leistungen angezeigt. + .unindented + %table.orders-report.table.table-hover + %thead + %tr + %th + = sort_link(:client, 'Kunde') + %br/ + Auftrag + %th.nowrap + Status + %th.right.nowrap{title: 'Produkt aus allen geleisteten Stunden und offeriertem Stundensatz'} + = sort_link(:supplied_amount, 'Geleistet') + %th.right.nowrap{title: 'Produkt aus allen verrechenbaren Stunden und offeriertem Stundensatz'} + = sort_link(:billable_amount, 'Verrechenbar') + %th.right.nowrap{title: 'Kostensumme der verrechenbaren Leistungen, die im gewählten Zeitraum erbracht wurden und auf einer Rechnung sind'} + = sort_link(:billed_amount, 'Verrechnete Leistungen') + %th.right.nowrap{title: 'Kostensumme aller verrechenbaren Leistungen, die im gewählten Zeitraum erbracht wurden und noch keiner Rechnung zugeteilt sind'} + = sort_link(:not_billed_amount, 'Verrechnung offen') + %th.center + + %tbody + - @report.page do |order| + - if order.instance_of? Billing::Report::Total + %td + = order + %td + %td + .data-item + %span.figure= f(order.supplied_amount.to_f) + %span.unit= currency + %td + .data-item + %span.figure= f(order.billable_amount.to_f) + %span.unit= currency + %td.right + .data-item + %span.figure= f(order.billed_amount.to_f) + %span.unit= currency + %td.right + .data-item + %span.figure= f(order.not_billed_amount.to_f) + %span.unit= currency + %td.center + - else + %tr + %td + - if order.order + = order.parent_names + %br/ + %span.subtitle + = link_to_if(can?(:show, order.order), + "#{order.work_item.path_shortnames}: #{order}", + can?(:update, order.order) ? edit_order_path(order) : order_path(order)) + - else + %span.subtitle= order + %td + - if order.status + %span{class: "label label-#{order.status.style}"} + = order.status.name + %br/ + - if order.closed_at + %span.data-item + %span.unit + = I18n.l(order.closed_at, format: '%d.%m.%Y') + %td + .data-item + %span.figure= f(order.supplied_amount.to_f) + %span.unit= currency + %td + .data-item + %span.figure= f(order.billable_amount.to_f) + %span.unit= currency + %td.right + .data-item + %span.figure= f(order.billed_amount.to_f) + %span.unit= currency + %td.right + .data-item + %span.figure= f(order.not_billed_amount.to_f) + %span.unit= currency + %td.center + = link_to(picon('time'), order_order_services_path(order_id: order.order, invoice_id: '[leer]', billable: true, start_date: @period.start_date, end_date: @period.end_date), title: 'noch nicht verrechnete Leistungen anzeigen') + + %p= paginate @report + + - else + .table= ti(:no_list_entries) + +- else + .table Bitte wählen Sie mindestes einen Filter aus. diff --git a/app/views/billing_reports/_submenu.html.haml b/app/views/billing_reports/_submenu.html.haml new file mode 100644 index 00000000..48478a26 --- /dev/null +++ b/app/views/billing_reports/_submenu.html.haml @@ -0,0 +1,6 @@ +-# Copyright (c) 2006-2017, Puzzle ITC GmbH. This file is part of +-# PuzzleTime and licensed under the Affero General Public License version 3 +-# or later. See the COPYING file at the top-level directory or at +-# https://github.com/puzzle/puzzletime. + += render 'orders/submenu' diff --git a/app/views/billing_reports/index.html.haml b/app/views/billing_reports/index.html.haml new file mode 100644 index 00000000..ab21cee9 --- /dev/null +++ b/app/views/billing_reports/index.html.haml @@ -0,0 +1,13 @@ +-# Copyright (c) 2006-2017, Puzzle ITC GmbH. This file is part of +-# PuzzleTime and licensed under the Affero General Public License version 3 +-# or later. See the COPYING file at the top-level directory or at +-# https://github.com/puzzle/puzzletime. + + +%ul.nav.nav-tabs{ role: 'tablist' } +- @title ||= 'Verrechnungs-Controlling' + +%p= render 'filters' + +.list + = render 'list' diff --git a/app/views/billing_reports/index.js.haml b/app/views/billing_reports/index.js.haml new file mode 100644 index 00000000..33010bc2 --- /dev/null +++ b/app/views/billing_reports/index.js.haml @@ -0,0 +1,7 @@ +-# Copyright (c) 2006-2017, Puzzle ITC GmbH. This file is part of +-# PuzzleTime and licensed under the Affero General Public License version 3 +-# or later. See the COPYING file at the top-level directory or at +-# https://github.com/puzzle/puzzletime. + +$('.list').html('#{j(render('list'))}'); += render 'shared/flash_alert' \ No newline at end of file diff --git a/app/views/orders/_submenu.html.haml b/app/views/orders/_submenu.html.haml index cbf3c935..f21bac9b 100644 --- a/app/views/orders/_submenu.html.haml +++ b/app/views/orders/_submenu.html.haml @@ -12,6 +12,11 @@ reports_orders_path(clear: 1), reports_orders_path +- if can?(:reports, Order) + = nav 'Verrechnungs-Controlling', + reports_billing_path(clear: 1), + reports_billing_path + - if can?(:managed, Evaluations::Evaluation) = nav 'Meine Aufträge', evaluation_path('managed', clear: 1) diff --git a/config/routes.rb b/config/routes.rb index 2f6ad9c0..21956c39 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -221,6 +221,7 @@ scope '/reports' do get :orders, to: 'order_reports#index', as: :reports_orders get :invoices, to: 'invoice_reports#index', as: :reports_invoices + get :billing, to: 'billing_reports#index', as: :reports_billing get :workload, to: 'workload_report#index', as: :reports_workload get :revenue, to: 'revenue_reports#index', as: :reports_revenue get :capacity, to: 'capacity_report#index', as: :reports_capacity diff --git a/test/domain/billing/report_test.rb b/test/domain/billing/report_test.rb new file mode 100644 index 00000000..3f50995d --- /dev/null +++ b/test/domain/billing/report_test.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +# Copyright (c) 2006-2017, Puzzle ITC GmbH. This file is part of +# PuzzleTime and licensed under the Affero General Public License version 3 +# or later. See the COPYING file at the top-level directory or at +# https://github.com/puzzle/puzzletime. + +require 'test_helper' + +module Billing + class ReportTest < ActiveSupport::TestCase + ### filtering + + test 'contains all orders that have > 0 hours of unbilled worktimes' do + assert_equal 3, report.entries.size + end + + test 'filter by status' do + report(status_id: order_statuses(:abgeschlossen)) + + assert_equal [orders(:allgemein)], report.entries.collect(&:order) + end + + test 'filter by responsible' do + report(responsible_id: employees(:long_time_john).id) + + assert_equal [orders(:webauftritt)], report.entries.collect(&:order) + end + + test 'filter by department' do + report(department_id: departments(:devtwo).id) + + assert_empty report.entries + end + + test 'filter by kind' do + report(kind_id: order_kinds(:projekt).id) + + assert_equal [orders(:webauftritt)], report.entries.collect(&:order) + end + + test 'filter by responsible and department' do + report(responsible_id: employees(:lucien).id, department_id: departments(:devone).id) + + assert_equal [orders(:puzzletime)], report.entries.collect(&:order) + end + + test 'filter too restrictive' do + report(kind_id: order_kinds(:mandat).id, + department_id: departments(:sys).id, + status_id: order_statuses(:bearbeitung).id) + + assert_empty report.entries + end + + test 'filter by client' do + report(client_work_item_id: work_items(:puzzle).id) + + assert_equal orders(:allgemein, :puzzletime), report.entries.collect(&:order) + end + + test 'filter by start date' do + report(period: Period.new(Date.new(2006, 12, 11), nil)) + + assert_equal [orders(:webauftritt)], report.entries.collect(&:order) + end + + test 'filter by end date' do + report(period: Period.new(nil, Date.new(2006, 12, 1))) + + assert_equal [orders(:allgemein)], report.entries.collect(&:order) + end + + test 'filter by period' do + report(period: Period.new(Date.new(2006, 12, 4), Date.new(2006, 12, 6))) + + assert_equal [orders(:allgemein)], report.entries.collect(&:order) + end + + ### sorting + + test 'sort by client' do + report(sort: 'client', sort_dir: 'desc') + + assert_equal orders(:allgemein, :puzzletime, :webauftritt), report.entries.collect(&:order) + end + + test 'sort by target time' do + report(sort: "target_scope_#{target_scopes(:time).id}", sort_dir: 'desc') + + assert_equal orders(:allgemein, :puzzletime, :webauftritt), report.entries.collect(&:order) + end + + test 'sort by not_billed_amount' do + report(sort: 'not_billed_amount', sort_dir: 'desc') + + assert_equal orders(:webauftritt, :puzzletime, :allgemein), report.entries.collect(&:order) + end + + ### calculating + + test 'it counts orders' do + assert_equal 'Total (3)', report.total.to_s + end + + test 'billable_amount is always sum of not_billed_amount and billed_amount' do + report.entries.each do |e| + assert_equal e.not_billed_amount + e.billed_amount, e.billable_amount + end + end + + test 'correctly reflect unbilled hours' do + order = orders(:webauftritt) + Fabricate(:contract, order:) + Fabricate(:ordertime, work_item: work_items(:webauftritt), employee: employees(:pascal), hours: 10, + work_date: '2020-10-10') + + Fabricate(:invoice, order:, work_items: [work_items(:webauftritt)], employees: [employees(:pascal)], + period_from: '2020-10-01', period_to: '2020-10-20', billing_date: '2020-12-01') + + entry_ptime = report.entries.find { |e| e.order == orders(:puzzletime) } + entry_webauftritt = report.entries.find { |e| e.order == orders(:webauftritt) } + + assert_in_delta 24, entry_ptime.not_billed_amount + assert_in_delta 2520, entry_webauftritt.not_billed_amount + assert_in_delta 1400, entry_webauftritt.billed_amount + end + + private + + def report(params = {}) + period = params.delete(:period) || Period.new(nil, nil) + @report ||= Billing::Report.new(period, params) + end + end +end