From: Michael Wagner Date: Sat, 6 Jun 2026 13:23:50 +0000 (+0200) Subject: date error in mapping X-Git-Tag: mfinanz-mebil_0.2-2 X-Git-Url: http://wagnertech.de/gitweb/gitweb.cgi/mfinanz.git/commitdiff_plain/refs/heads/master?hp=6982501f82e4e2383a69aae3286275b7e9f66475 date error in mapping --- diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..ea87793ba --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,36 @@ +on: + push: + branches: + - "**" + pull_request: + branches: + - master + workflow_dispatch: + +jobs: + build: + name: kivi-tests + runs-on: ubuntu-22.04 + steps: + - name: Check out repository code + uses: actions/checkout@v4 + - name: Installiere Perl Module und Postgresql + run: | + sudo rm /etc/apt/sources.list.d/microsoft-prod.list + sudo add-apt-repository universe + sudo apt-get update + sudo apt-get install postgresql postgresql-contrib libtest-deep-perl libtest-exception-perl libtest-output-perl libwww-perl liburi-find-perl libsys-cpu-perl libthread-pool-simple-perl libdbi-perl liblist-moreutils-perl libyaml-perl libregexp-ipv6-perl libpbkdf2-tiny-perl librose-object-perl librose-db-perl librose-db-object-perl libdigest-perl-md5-perl liblist-utilsby-perl libalgorithm-checkdigits-perl libhtml-restrict-perl libfile-slurp-perl libsort-naturally-perl libmath-round-perl libtext-csv-xs-perl libtemplate-perl libcam-pdf-perl libxml-libxml-perl libxml-writer-perl libemail-address-perl libemail-mime-perl libarchive-zip-perl libimager-perl libimager-qrcode-perl libstring-shellquote-perl libgd-gd2-perl libimage-info-perl libconfig-std-perl libdbd-pg-perl libdatetime-event-cron-perl libfile-copy-recursive-perl librest-client-perl libipc-run-perl libfile-mimeinfo-perl libencode-imaputf7-perl libmail-imapclient-perl libhttp-dav-perl libpdf-api2-perl libppi-perl cpanminus libuuid-tiny-perl + - name: install cpan modules that don't have deb packages + run: | + cpanm -L ${{ runner.temp }}/cpan HTML::Query + - name: Configurieren + run: | + cp config/kivitendo.conf.default config/kivitendo.conf + sed -i '/db[ ]*=/ s/$/ testdb/; /host/ s/localhost/127\.0\.0\.1/' config/kivitendo.conf + sudo sed -i '/host[ ]*all[ ]*all[ ]*127/s/scram-sha-256/trust/' /etc/postgresql/14/main/pg_hba.conf + - name: Postgresql starten + run: sudo service postgresql start + - name: Starten der Tests... + run: t/test.pl + env: + PERL5LIB: ${{ runner.temp }}/cpan/lib/perl5 diff --git a/.gitignore b/.gitignore index 5c8f7d824..0783c911f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,7 @@ /users/session_files/ /users/templates-cache/ /users/templates-cache-for-tests/ -/users/xvfb_display +/users/texmf/ /webdav/* crm pod2html* diff --git a/.mailmap b/.mailmap index bfb19e55b..aea483408 100644 --- a/.mailmap +++ b/.mailmap @@ -34,6 +34,9 @@ Roman Karuschka Roman Karushka roman Sven Schöling Sven Schöling +Tamino Steinert Tamino +Tamino Steinert Tamino +Tamino Steinert Tamino Steinert <74305567+z4m1n0@users.noreply.github.com> Timo Eickmeyer T. Eickmeyer Waldemar Toews Wulf Coulmann Wulf diff --git a/README.md b/README.md new file mode 100644 index 000000000..37c0afc19 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +
+

kivitendo-erp

+
+A Web-based ERP system for the German market.

+
+ +**kivitendo is a basic ERP system capable of handling:** + +- articles +- goods +- customers +- suppliers +- stock +- offers +- invoices +- collection +- booking preparation for financial accounting +- contacts with basic CRM functions and much more. + +Although kivitendo is developed for the German speaking market, the UI is also available in English. + +## Online demo + +You can try it out [here](https://www.kivitendo.de/controller.pl?action=LoginScreen/user_login). + +## Installation + +An in depth installation guide can be found in the documentation (in German). + +You can also use our [ansible-playbook](https://github.com/kivitendo/kivitendo-ansible) to install it on an Ubuntu base. + +## Documentation + +Documentation for installation and configuration is only available in German, and can be found [here](https://www.kivitendo.de/kivi/doc/html/). + +The API is mostly documented in English in POD format and can be accessed with the perldoc command. + +## Mailing list + +To be updated regularly you can sign up for our mailing list [here](https://sourceforge.net/projects/lx-office/lists/lx-office-devel). diff --git a/SL/AM.pm b/SL/AM.pm index 2d8ed4cff..56faba3b2 100644 --- a/SL/AM.pm +++ b/SL/AM.pm @@ -57,6 +57,7 @@ use SL::Helper::UserPreferences::PositionsScrollbar; use SL::Helper::UserPreferences::PartPickerSearch; use SL::Helper::UserPreferences::TimeRecording; use SL::Helper::UserPreferences::UpdatePositions; +use SL::Helper::UserPreferences::ItemInputPosition; use strict; @@ -90,14 +91,17 @@ sub get_account { my $chart_obj = SL::DB::Manager::Chart->find_by(id => $form->{id}) || die "Can't open chart"; my @chart_fields = qw(accno description charttype category link pos_bilanz - pos_eur pos_er new_chart_id valid_from pos_bwa datevautomatik); + pos_eur pos_er new_chart_id valid_from pos_bwa datevautomatik + invalid); foreach my $cf ( @chart_fields ) { $form->{"$cf"} = $chart_obj->$cf; } my $active_taxkey = $chart_obj->get_active_taxkey; - $form->{$_} = $active_taxkey->$_ foreach qw(taxkey_id pos_ustva tax_id startdate); - $form->{tax} = $active_taxkey->tax_id . '--' . $active_taxkey->taxkey_id; + if ($active_taxkey) { + $form->{$_} = $active_taxkey->$_ foreach qw(taxkey_id pos_ustva tax_id startdate); + $form->{tax} = $active_taxkey->tax_id . '--' . $active_taxkey->taxkey_id; + } # check if there are any transactions for this chart $form->{orphaned} = $chart_obj->has_transaction ? 0 : 1; @@ -232,7 +236,8 @@ sub _save_account { pos_er = ?, new_chart_id = ?, valid_from = ?, - datevautomatik = ? + datevautomatik = ?, + invalid = ? WHERE id = ?|; @values = ( @@ -248,6 +253,7 @@ sub _save_account { conv_i($form->{new_chart_id}), conv_date($form->{valid_from}), ($form->{datevautomatik} eq 'T') ? 'true':'false', + $form->{invalid} ? 'true' : 'false', $form->{id}, ); @@ -556,6 +562,18 @@ sub longdescription_dialog_size_percentage { SL::Helper::UserPreferences::DisplayPreferences->new()->get_longdescription_dialog_size_percentage(); } +sub layout_style { + SL::Helper::UserPreferences::DisplayPreferences->new()->get_layout_style(); +} + +sub part_picker_search_all_as_list_default { + SL::Helper::UserPreferences::PartPickerSearch->new()->get_all_as_list_default(); +} + +sub order_item_input_position { + SL::Helper::UserPreferences::ItemInputPosition->new()->get_order_item_input_position(); +} + sub save_preferences { $main::lxdebug->enter_sub(); @@ -599,6 +617,15 @@ sub save_preferences { if (exists $form->{longdescription_dialog_size_percentage}) { SL::Helper::UserPreferences::DisplayPreferences->new()->store_longdescription_dialog_size_percentage($form->{longdescription_dialog_size_percentage}) } + if (exists $form->{layout_style}) { + SL::Helper::UserPreferences::DisplayPreferences->new()->store_layout_style($form->{layout_style}) + } + if (exists $form->{part_picker_search_all_as_list_default}) { + SL::Helper::UserPreferences::PartPickerSearch->new()->store_all_as_list_default($form->{part_picker_search_all_as_list_default}) + } + if (exists $form->{order_item_input_position}) { + SL::Helper::UserPreferences::ItemInputPosition->new()->store_order_item_input_position($form->{order_item_input_position}) + } $main::lxdebug->leave_sub(); @@ -1393,7 +1420,8 @@ sub get_warehouse { UNION SELECT DISTINCT bin_id, TRUE AS in_use FROM parts ) use ON use.bin_id = b.id - WHERE b.warehouse_id = ?; + WHERE b.warehouse_id = ? + ORDER by description; SQL $form->{BINS} = selectall_hashref_query($form, $dbh, $query, conv_i($form->{id})); diff --git a/SL/AP.pm b/SL/AP.pm index d399b7c1b..753c94b5f 100644 --- a/SL/AP.pm +++ b/SL/AP.pm @@ -44,11 +44,14 @@ use SL::DB::Default; use SL::DB::Draft; use SL::DB::Order; use SL::DB::PurchaseInvoice; +use SL::DB::EmailJournal; +use SL::DB::ValidityToken; use SL::Util qw(trim); use SL::DB; use Data::Dumper; use List::Util qw(sum0); use strict; +use URI::Escape; sub post_transaction { my ($self, $myconfig, $form, $provided_dbh, %params) = @_; @@ -63,6 +66,16 @@ sub post_transaction { sub _post_transaction { my ($self, $myconfig, $form, $provided_dbh, %params) = @_; + my $validity_token; + if (!$form->{id}) { + $validity_token = SL::DB::Manager::ValidityToken->fetch_valid_token( + scope => SL::DB::ValidityToken::SCOPE_PURCHASE_INVOICE_POST(), + token => $form->{form_validity_token}, + ); + + die $::locale->text('The form is not valid anymore.') if !$validity_token; + } + my $payments_only = $params{payments_only}; my $dbh = $provided_dbh || SL::DB->client->dbh; @@ -71,14 +84,50 @@ sub _post_transaction { $form->{defaultcurrency} = $form->get_default_currency($myconfig); $form->{taxincluded} = 0 unless $form->{taxincluded}; + $form->{script} = 'ap.pl' unless $form->{script}; + + # make sure to have a id + my ($query, $sth, @values); + if (!$payments_only) { + # if we have an id delete old records + if ($form->{id}) { + # delete detail records + $query = qq|DELETE FROM acc_trans WHERE trans_id = ?|; + do_query($form, $dbh, $query, $form->{id}); + + } else { + + ($form->{id}) = selectrow_query($form, $dbh, qq|SELECT nextval('glid')|); + + $query = + qq|INSERT INTO ap (id, invnumber, employee_id,currency_id, taxzone_id) | . + qq|VALUES (?, ?, (SELECT e.id FROM employee e WHERE e.login = ?), + (SELECT id FROM currencies WHERE name = ?), (SELECT taxzone_id FROM vendor WHERE id = ?) )|; + do_query($form, $dbh, $query, $form->{id}, $form->{invnumber}, $::myconfig{login}, $form->{currency}, $form->{vendor_id}); + + } + } + # check default or record exchangerate if ($form->{currency} eq $form->{defaultcurrency}) { $form->{exchangerate} = 1; } else { $exchangerate = $form->check_exchangerate($myconfig, $form->{currency}, $form->{transdate}, 'sell'); - $form->{exchangerate} = $exchangerate || $form->parse_amount($myconfig, $form->{exchangerate}); + $form->{exchangerate} = $form->parse_amount($myconfig, $form->{exchangerate}, 5); + + # if default exchangerate is not defined, define one + unless ($exchangerate) { + $form->update_exchangerate($dbh, $form->{currency}, $form->{transdate}, 0, $form->{exchangerate}); + # delete records exchangerate -> if user sets new invdate for record + $query = qq|UPDATE ap set exchangerate = NULL where id = ?|; + do_query($form, $dbh, $query, $form->{"id"}); + } + # update record exchangerate, if the default is set and differs from current + if ($exchangerate && ($form->{exchangerate} != $exchangerate)) { + $form->update_exchangerate($dbh, $form->{currency}, $form->{transdate}, + 0, $form->{exchangerate}, $form->{id}, 'ap'); + } } - # get the charts selected $form->{AP_amounts}{"amount_$_"} = $form->{"AP_amount_chart_id_$_"} for (1 .. $form->{rowcount}); @@ -110,39 +159,13 @@ sub _post_transaction { # amount for total AP $form->{payables} = $form->{invtotal}; - # update exchangerate - if (($form->{currency} ne $form->{defaultcurrency}) && !$exchangerate) { - $form->update_exchangerate($dbh, $form->{currency}, $form->{transdate}, 0, - $form->{exchangerate}); - } - - my ($query, $sth, @values); - if (!$payments_only) { - # if we have an id delete old records - if ($form->{id}) { - - # delete detail records - $query = qq|DELETE FROM acc_trans WHERE trans_id = ?|; - do_query($form, $dbh, $query, $form->{id}); - - } else { - - ($form->{id}) = selectrow_query($form, $dbh, qq|SELECT nextval('glid')|); - - $query = - qq|INSERT INTO ap (id, invnumber, employee_id,currency_id, taxzone_id) | . - qq|VALUES (?, ?, (SELECT e.id FROM employee e WHERE e.login = ?), - (SELECT id FROM currencies WHERE name = ?), (SELECT taxzone_id FROM vendor WHERE id = ?) )|; - do_query($form, $dbh, $query, $form->{id}, $form->{invnumber}, $::myconfig{login}, $form->{currency}, $form->{vendor_id}); - - } - $query = qq|UPDATE ap SET invnumber = ?, transdate = ?, ordnumber = ?, vendor_id = ?, taxincluded = ?, amount = ?, duedate = ?, deliverydate = ?, tax_point = ?, paid = ?, netamount = ?, currency_id = (SELECT id FROM currencies WHERE name = ?), notes = ?, department_id = ?, storno = ?, storno_id = ?, - globalproject_id = ?, direct_debit = ?, payment_id = ?, transaction_description = ? + globalproject_id = ?, direct_debit = ?, payment_id = ?, transaction_description = ?, intnotes = ?, + qrbill_data = ? WHERE id = ?|; @values = ($form->{invnumber}, conv_date($form->{transdate}), $form->{ordnumber}, conv_i($form->{vendor_id}), @@ -154,6 +177,8 @@ sub _post_transaction { $form->{storno_id}, conv_i($form->{globalproject_id}), $form->{direct_debit} ? 't' : 'f', conv_i($form->{payment_id}), $form->{transaction_description}, + $form->{intnotes}, + $form->{qrbill_data_encoded} ? uri_unescape($form->{qrbill_data_encoded}) : undef, $form->{id}); do_query($form, $dbh, $query, @values); @@ -161,7 +186,7 @@ sub _post_transaction { # Link this record to the record it was created from. my $convert_from_oe_id = delete $form->{convert_from_oe_id}; - if (!$form->{postasnew} && $convert_from_oe_id) { + if ($convert_from_oe_id) { RecordLinks->create_links('dbh' => $dbh, 'mode' => 'ids', 'from_table' => 'oe', @@ -201,7 +226,7 @@ sub _post_transaction { $form->{"AP_amount_chart_id_$i"}); do_query($form, $dbh, $query, @values); - if ($form->{"tax_$i"} != 0) { + if ($form->{"tax_$i"} != 0 && !$form->{"reverse_charge_$i"}) { # insert detail records in acc_trans $query = qq|INSERT INTO acc_trans (trans_id, chart_id, amount, transdate, | . @@ -408,7 +433,7 @@ sub _post_transaction { } # hook for taxkey 94 - $self->_reverse_charge($myconfig, $form); + $self->_reverse_charge($myconfig, $form) unless $payments_only; # safety check datev export if ($::instance_conf->get_datev_check_on_ap_transaction) { my $datev = SL::DATEV->new( @@ -422,6 +447,9 @@ sub _post_transaction { } } + $validity_token->delete if $validity_token; + delete $form->{form_validity_token}; + return 1; } @@ -526,7 +554,7 @@ sub ap_transactions { my $query = qq|SELECT a.id, a.invnumber, a.transdate, a.duedate, a.amount, a.paid, | . qq| a.ordnumber, v.name, a.invoice, a.netamount, a.datepaid, a.notes, | . - qq| a.globalproject_id, a.storno, a.storno_id, a.direct_debit, | . + qq| a.intnotes, a.globalproject_id, a.storno, a.storno_id, a.direct_debit, | . qq| a.transaction_description, a.itime::DATE AS insertdate, | . qq| pr.projectnumber AS globalprojectnumber, | . qq| e.name AS employee, | . @@ -622,6 +650,10 @@ sub ap_transactions { $where .= " AND a.taxzone_id = ?"; push(@values, $form->{taxzone_id}); } + if ($form->{payment_id}) { + $where .= " AND a.payment_id = ?"; + push(@values, $form->{payment_id}); + } if ($form->{transaction_description}) { $where .= " AND a.transaction_description ILIKE ?"; push(@values, like($form->{transaction_description})); @@ -630,6 +662,10 @@ sub ap_transactions { $where .= " AND a.notes ILIKE ?"; push(@values, like($form->{notes})); } + if ($form->{intnotes}) { + $where .= " AND a.intnotes ILIKE ?"; + push(@values, like($form->{intnotes})); + } if ($form->{project_id}) { $where .= qq| AND ((a.globalproject_id = ?) OR EXISTS | . @@ -658,6 +694,22 @@ sub ap_transactions { $where .= " AND a.duedate <= ?"; push(@values, trim($form->{duedateto})); } + if ($form->{datepaidfrom}) { + $where .= " AND a.datepaid >= ?"; + push(@values, trim($form->{datepaidfrom})); + } + if ($form->{datepaidto}) { + $where .= " AND a.datepaid <= ?"; + push(@values, trim($form->{datepaidto})); + } + if ($form->{insertdatefrom}) { + $where .= " AND a.itime >= ?"; + push(@values, trim($form->{insertdatefrom})); + } + if ($form->{insertdateto}) { + $where .= " AND a.itime <= ?"; + push(@values, trim($form->{insertdateto})); + } if ($form->{open} || $form->{closed}) { unless ($form->{open} && $form->{closed}) { $where .= " AND a.amount <> a.paid" if ($form->{open}); @@ -665,6 +717,29 @@ sub ap_transactions { } } + $form->{fulltext} = trim($form->{fulltext}); + if ($form->{fulltext}) { + my @fulltext_fields = qw(a.notes + a.intnotes + a.shipvia + a.transaction_description + a.quonumber + a.ordnumber + a.invnumber); + $where .= ' AND ('; + $where .= join ' OR ', map {"$_ ILIKE ?"} @fulltext_fields; + + $where .= <{fulltext})) for 1 .. (scalar @fulltext_fields) + 1; + } + if ($form->{parts_partnumber}) { $where .= <{sortdir} ? 'ASC' : $form->{sortdir} ? 'ASC' : 'DESC'; my $sortorder = join(', ', map { "$_ $sortdir" } @a); - if (grep({ $_ eq $form->{sort} } qw(transdate id invnumber ordnumber name netamount tax amount paid datepaid due duedate notes employee transaction_description direct_debit department taxzone))) { + if (grep({ $_ eq $form->{sort} } qw(transdate id invnumber ordnumber name netamount tax amount paid datepaid due duedate notes employee transaction_description direct_debit department taxzone insertdate))) { $sortorder = $form->{sort} . " $sortdir"; } @@ -712,6 +787,26 @@ SQL $form->{AP} = [ @result ]; + if ($form->{l_items} && scalar @{ $form->{AP} }) { + my ($items_query, $items_sth); + if ($form->{l_items}) { + $items_query = + qq|SELECT id + FROM invoice + WHERE trans_id = ? + ORDER BY position|; + + $items_sth = prepare_query($form, $dbh, $items_query); + } + + foreach my $ap (@{ $form->{AP} }) { + do_statement($form, $items_sth, $items_query, $ap->{id}); + $ap->{item_ids} = $dbh->selectcol_arrayref($items_sth); + $ap->{item_ids} = undef if !@{$ap->{item_ids}}; + } + $items_sth->finish(); + } + $main::lxdebug->leave_sub(); } @@ -895,6 +990,8 @@ sub setup_form { $form->{"forex_$j"} = $form->{"exchangerate_$i"}; $form->{"AP_paid_$j"} = $form->{acc_trans}{$key}->[$i-1]->{accno}; $form->{"paid_project_id_$j"} = $form->{acc_trans}{$key}->[$i - 1]->{project_id}; + $form->{"defaultcurrency_paid_$j"} = $form->{acc_trans}{$key}->[$i - 1]->{defaultcurrency_paid}; + $form->{"fx_transaction_$j"} = $form->{acc_trans}{$key}->[$i - 1]->{fx_transaction}; $form->{paidaccounts}++; } else { @@ -1016,6 +1113,19 @@ sub _storno { map { IO->set_datepaid(table => 'ap', id => $_, dbh => $dbh) } ($id, $new_id); + if ($form->{workflow_email_journal_id}) { + my $ap_transaction_storno = SL::DB::PurchaseInvoice->new(id => $new_id)->load; + my $email_journal = SL::DB::EmailJournal->new( + id => delete $form->{workflow_email_journal_id} + )->load; + $email_journal->link_to_record_with_attachment( + $ap_transaction_storno, + delete $form->{workflow_email_attachment_id} + ); + $form->{callback} = delete $form->{workflow_email_callback}; + } + + $form->{storno_id} = $id; return 1; } diff --git a/SL/AR.pm b/SL/AR.pm index db8a4aca7..95a2cefba 100644 --- a/SL/AR.pm +++ b/SL/AR.pm @@ -42,6 +42,9 @@ use SL::DB::Draft; use SL::IO; use SL::MoreCommon; use SL::DB::Default; +use SL::DB::Invoice; +use SL::DB::EmailJournal; +use SL::DB::ValidityToken; use SL::TransNumber; use SL::Util qw(trim); use SL::DB; @@ -61,21 +64,67 @@ sub post_transaction { sub _post_transaction { my ($self, $myconfig, $form, $provided_dbh, %params) = @_; + my $validity_token; + if (!$form->{id}) { + $validity_token = SL::DB::Manager::ValidityToken->fetch_valid_token( + scope => SL::DB::ValidityToken::SCOPE_SALES_INVOICE_POST(), + token => $form->{form_validity_token}, + ); + + die $::locale->text('The form is not valid anymore.') if !$validity_token; + } + my $payments_only = $params{payments_only}; my ($query, $sth, $null, $taxrate, $amount, $tax); my $exchangerate = 0; my $i; + $form->{script} = 'ar.pl' unless $form->{script}; my @values; my $dbh = $provided_dbh || SL::DB->client->dbh; - $form->{defaultcurrency} = $form->get_default_currency($myconfig); - # set exchangerate - $form->{exchangerate} = ($form->{currency} eq $form->{defaultcurrency}) ? 1 : - ( $form->check_exchangerate($myconfig, $form->{currency}, $form->{transdate}, 'buy') || - $form->parse_amount($myconfig, $form->{exchangerate}) ); + # if we have an id delete old records else make one + if (!$payments_only) { + if ($form->{id}) { + # delete detail records + $query = qq|DELETE FROM acc_trans WHERE trans_id = ?|; + do_query($form, $dbh, $query, $form->{id}); + + } else { + $query = qq|SELECT nextval('glid')|; + ($form->{id}) = selectrow_query($form, $dbh, $query); + $query = qq|INSERT INTO ar (id, invnumber, employee_id, currency_id, taxzone_id) VALUES (?, 'dummy', ?, (SELECT id FROM currencies WHERE name=?), (SELECT taxzone_id FROM customer WHERE id = ?))|; + do_query($form, $dbh, $query, $form->{id}, $form->{employee_id}, $form->{currency}, $form->{customer_id}); + if (!$form->{invnumber}) { + my $trans_number = SL::TransNumber->new(type => 'invoice', dbh => $dbh, number => $form->{partnumber}, id => $form->{id}); + $form->{invnumber} = $trans_number->create_unique; + } + } + } + + $form->{defaultcurrency} = $form->get_default_currency($myconfig); + # check default or record exchangerate + if ($form->{currency} eq $form->{defaultcurrency}) { + $form->{exchangerate} = 1; + } else { + $exchangerate = $form->check_exchangerate($myconfig, $form->{currency}, $form->{transdate}, 'buy'); + $form->{exchangerate} = $form->parse_amount($myconfig, $form->{exchangerate}, 5); + + # if default exchangerate is not defined, define one + unless ($exchangerate) { + $form->update_exchangerate($dbh, $form->{currency}, $form->{transdate}, $form->{exchangerate}, 0); + # delete records exchangerate -> if user sets new invdate for record + $query = qq|UPDATE ar set exchangerate = NULL where id = ?|; + do_query($form, $dbh, $query, $form->{"id"}); + } + # update record exchangerate, if the default is set and differs from current + if ($exchangerate && ($form->{exchangerate} != $exchangerate)) { + $form->update_exchangerate($dbh, $form->{currency}, $form->{transdate}, + $form->{exchangerate}, 0, $form->{id}, 'ar'); + } + } # get the charts selected $form->{AR_amounts}{"amount_$_"} = $form->{"AR_amount_chart_id_$_"} for (1 .. $form->{rowcount}); @@ -104,32 +153,11 @@ sub _post_transaction { $form->get_employee($dbh) unless $form->{employee_id}; - # if we have an id delete old records else make one - if (!$payments_only) { - if ($form->{id}) { - # delete detail records - $query = qq|DELETE FROM acc_trans WHERE trans_id = ?|; - do_query($form, $dbh, $query, $form->{id}); - } else { - $query = qq|SELECT nextval('glid')|; - ($form->{id}) = selectrow_query($form, $dbh, $query); - $query = qq|INSERT INTO ar (id, invnumber, employee_id, currency_id, taxzone_id) VALUES (?, 'dummy', ?, (SELECT id FROM currencies WHERE name=?), (SELECT taxzone_id FROM customer WHERE id = ?))|; - do_query($form, $dbh, $query, $form->{id}, $form->{employee_id}, $form->{currency}, $form->{customer_id}); - if (!$form->{invnumber}) { - my $trans_number = SL::TransNumber->new(type => 'invoice', dbh => $dbh, number => $form->{partnumber}, id => $form->{id}); - $form->{invnumber} = $trans_number->create_unique; - } - } - } # amount for AR account $form->{receivables} = $form->round_amount($form->{amount}, 2) * -1; - # update exchangerate - $form->update_exchangerate($dbh, $form->{currency}, $form->{transdate}, $form->{exchangerate}, 0) - if ($form->{currency} ne $form->{defaultcurrency}) && !$form->check_exchangerate($myconfig, $form->{currency}, $form->{transdate}, 'buy'); - if (!$payments_only) { $query = qq|UPDATE ar set @@ -337,6 +365,9 @@ sub _post_transaction { } } + $validity_token->delete if $validity_token; + delete $form->{form_validity_token}; + return 1; } @@ -491,13 +522,15 @@ sub ar_transactions { qq| a.type, | . qq| pr.projectnumber AS globalprojectnumber, | . qq| c.name, c.customernumber, c.country, c.ustid, b.description as customertype, | . - qq| c.id as customer_id, | . + qq| c.id as customer_id, c.dunning_lock as customer_dunning_lock,| . qq| e.name AS employee, | . qq| e2.name AS salesman, | . qq| dc.dunning_description, | . qq| tz.description AS taxzone, | . qq| pt.description AS payment_terms, | . qq| d.description AS department, | . + qq| s.shiptoname, s.shiptodepartment_1, s.shiptodepartment_2, | . + qq| s.shiptostreet, s.shiptozipcode, s.shiptocity, s.shiptocountry, | . qq{ ( SELECT ch.accno || ' -- ' || ch.description FROM acc_trans at LEFT JOIN chart ch ON ch.id = at.chart_id @@ -515,6 +548,10 @@ sub ar_transactions { qq|LEFT JOIN tax_zones tz ON (tz.id = a.taxzone_id)| . qq|LEFT JOIN payment_terms pt ON (pt.id = a.payment_id)| . qq|LEFT JOIN business b ON (b.id = c.business_id)| . + qq|LEFT JOIN shipto s ON ( + (a.shipto_id = s.shipto_id) or + (a.id = s.trans_id and s.module = 'AR') + )| . qq|LEFT JOIN department d ON (d.id = a.department_id)|; my $where = "1 = 1"; @@ -577,6 +614,14 @@ sub ar_transactions { $where .= " AND a.taxzone_id = ?"; push(@values, $form->{taxzone_id}); } + if ($form->{department_id}) { + $where .= " AND a.department_id = ?"; + push(@values, $form->{department_id}); + } + if ($form->{payment_id}) { + $where .= " AND a.payment_id = ?"; + push(@values, $form->{payment_id}); + } foreach my $column (qw(invnumber ordnumber cusordnumber notes transaction_description shipvia shippingpoint)) { if ($form->{$column}) { $where .= " AND a.$column ILIKE ?"; @@ -585,7 +630,7 @@ sub ar_transactions { } if ($form->{"project_id"}) { $where .= - qq|AND ((a.globalproject_id = ?) OR EXISTS | . + qq| AND ((a.globalproject_id = ?) OR EXISTS | . qq| (SELECT * FROM invoice i | . qq| WHERE i.project_id = ? AND i.trans_id = a.id) | . qq| OR EXISTS | . @@ -611,6 +656,14 @@ sub ar_transactions { $where .= " AND a.duedate <= ?"; push(@values, trim($form->{duedateto})); } + if ($form->{datepaidfrom}) { + $where .= " AND a.datepaid >= ?"; + push(@values, trim($form->{datepaidfrom})); + } + if ($form->{datepaidto}) { + $where .= " AND a.datepaid <= ?"; + push(@values, trim($form->{datepaidto})); + } if ($form->{open} || $form->{closed}) { unless ($form->{open} && $form->{closed}) { $where .= " AND a.amount <> a.paid" if ($form->{open}); @@ -671,6 +724,35 @@ SQL $where .= ' AND COALESCE(paid_difference.amount, 0) + a.paid != 0'; } + if ($form->{shiptoname}) { + $where .= " AND s.shiptoname ILIKE ?"; + push(@values, like($form->{shiptoname})); + } + if ($form->{shiptodepartment_1}) { + $where .= " AND s.shiptodepartment_1 ILIKE ?"; + push(@values, like($form->{shiptodepartment_1})); + } + if ($form->{shiptodepartment_2}) { + $where .= " AND s.shiptodepartment_2 ILIKE ?"; + push(@values, like($form->{shiptodepartment_2})); + } + if ($form->{shiptostreet}) { + $where .= " AND s.shiptostreet ILIKE ?"; + push(@values, like($form->{shiptostreet})); + } + if ($form->{shiptozipcode}) { + $where .= " AND s.shiptozipcode ILIKE ?"; + push(@values, like($form->{shiptozipcode})); + } + if ($form->{shiptocity}) { + $where .= " AND s.shiptocity ILIKE ?"; + push(@values, like($form->{shiptocity})); + } + if ($form->{shiptocountry}) { + $where .= " AND s.shiptocountry ILIKE ?"; + push(@values, like($form->{shiptocountry})); + } + my ($cvar_where, @cvar_values) = CVar->build_filter_query('module' => 'CT', 'trans_id_field' => 'c.id', 'filter' => $form, @@ -695,6 +777,26 @@ SQL $form->{AR} = [ @result ]; + if ($form->{l_items} && scalar @{ $form->{AR} }) { + my ($items_query, $items_sth); + if ($form->{l_items}) { + $items_query = + qq|SELECT id + FROM invoice + WHERE trans_id = ? + ORDER BY position|; + + $items_sth = prepare_query($form, $dbh, $items_query); + } + + foreach my $ar (@{ $form->{AR} }) { + do_statement($form, $items_sth, $items_query, $ar->{id}); + $ar->{item_ids} = $dbh->selectcol_arrayref($items_sth); + $ar->{item_ids} = undef if !@{$ar->{item_ids}}; + } + $items_sth->finish(); + } + $main::lxdebug->leave_sub(); } @@ -755,6 +857,8 @@ sub setup_form { $form->{"forex_$j"} = $form->{acc_trans}{$key}->[$i - 1]->{exchangerate}; $form->{"exchangerate_$i"} = $form->{"forex_$j"}; $form->{"paid_project_id_$j"} = $form->{acc_trans}{$key}->[$i - 1]->{project_id}; + $form->{"defaultcurrency_paid_$j"} = $form->{acc_trans}{$key}->[$i - 1]->{defaultcurrency_paid}; + $form->{"fx_transaction_$j"} = $form->{acc_trans}{$key}->[$i - 1]->{fx_transaction}; $form->{paidaccounts}++; } else { # e.g. AR_amount, AR, AR_tax @@ -885,6 +989,19 @@ sub _storno { map { IO->set_datepaid(table => 'ar', id => $_, dbh => $dbh) } ($id, $new_id); + if ($form->{workflow_email_journal_id}) { + my $ar_transaction_storno = SL::DB::Invoice->new(id => $new_id)->load; + my $email_journal = SL::DB::EmailJournal->new( + id => delete $form->{workflow_email_journal_id} + )->load; + $email_journal->link_to_record_with_attachment( + $ar_transaction_storno, + delete $form->{workflow_email_attachment_id} + ); + $form->{callback} = delete $form->{workflow_email_callback}; + } + + $form->{storno_id} = $id; return 1; } diff --git a/SL/Auth.pm b/SL/Auth.pm index 6be693382..5d97aa01f 100644 --- a/SL/Auth.pm +++ b/SL/Auth.pm @@ -12,6 +12,7 @@ use Regexp::IPv6 qw($IPv6_re); use SL::Auth::ColumnInformation; use SL::Auth::Constants qw(:all); use SL::Auth::DB; +use SL::Auth::HTTPHeaders; use SL::Auth::LDAP; use SL::Auth::Password; use SL::Auth::SessionValue; @@ -152,7 +153,7 @@ sub _read_auth_config { foreach my $module (split m{ +}, $self->{module}) { my $config_name; ($module, $config_name) = split m{:}, $module, 2; - $config_name ||= $module eq 'DB' ? 'database' : lc($module); + $config_name ||= $module eq 'DB' ? 'database' : $module eq 'HTTPHeaders' ? 'http_headers' : lc($module); my $config = $::lx_office_conf{'authentication/' . $config_name}; if (!$config) { @@ -166,6 +167,9 @@ sub _read_auth_config { } elsif ($module eq 'LDAP') { push @{ $self->{authenticators} }, SL::Auth::LDAP->new($config); + } elsif ($module eq 'HTTPHeaders') { + push @{ $self->{authenticators} }, SL::Auth::HTTPHeaders->new($config); + } else { my $locale = Locale->new('en'); $self->mini_error($locale->text('Unknown authenticantion module #1 specified in "config/kivitendo.conf".', $module)); @@ -228,6 +232,12 @@ sub authenticate_root { return $result; } +sub set_session_authenticated { + my ($self, $login, $result) = @_; + + $self->set_session_value(SESSION_KEY_USER_AUTH() => $result, login => $login, client_id => $self->client->{id}); +} + sub authenticate { my ($self, $login, $password) = @_; @@ -252,7 +262,8 @@ sub authenticate { } } - $self->set_session_value(SESSION_KEY_USER_AUTH() => $result, login => $login, client_id => $self->client->{id}); + $self->set_session_authenticated($login, $result); + return $result; } @@ -513,6 +524,9 @@ sub read_user { my %user_data; + # Set defaults for options not present in database + $user_data{follow_up_notify_by_email} = 1; + while (my $ref = $sth->fetchrow_hashref()) { $user_data{$ref->{cfg_key}} = $ref->{cfg_value}; @user_data{qw(id login)} = @{$ref}{qw(id login)}; diff --git a/SL/Auth/HTTPHeaders.pm b/SL/Auth/HTTPHeaders.pm new file mode 100644 index 000000000..e0f2aafb6 --- /dev/null +++ b/SL/Auth/HTTPHeaders.pm @@ -0,0 +1,161 @@ +package SL::Auth::HTTPHeaders; + +use List::MoreUtils qw(any); + +use SL::Auth::Constants qw(:all); + +use strict; + +my @required_config_options = qw(secret_header secret user_header client_id_header); + +sub new { + my $type = shift; + my $self = {}; + $self->{config} = shift; + + bless $self, $type; + + return $self; +} + +sub reset { + my ($self) = @_; +} + +sub _env_var_for_header { + my ($header) = @_; + + $header =~ s{-}{_}g; + return $ENV{'HTTP_' . uc($header)}; +} + +sub _authenticate { + my ($self, $type) = @_; + + my $secret = _env_var_for_header($self->{config}->{secret_header}) // ''; + if ($secret ne $self->{config}->{secret}) { + $::lxdebug->message(LXDebug->DEBUG2(), "HTTPHeaders ${type}: bad secret sent by upstream server: $secret"); + return (ERR_BACKEND); + } + + my $client_id = _env_var_for_header($self->{config}->{client_id_header}); + if (!$client_id) { + $::lxdebug->message(LXDebug->DEBUG2(), "HTTPHeaders ${type}: no client ID header found"); + return (ERR_PASSWORD); + } + + # $::auth->set_client(); + + my $user = _env_var_for_header($self->{config}->{user_header}); + if (!$user) { + $::lxdebug->message(LXDebug->DEBUG2(), "HTTPHeaders ${type}: no user name header found"); + return (ERR_PASSWORD); + } + + $::lxdebug->message(LXDebug->DEBUG2(), "HTTPHeaders ${type}: OK client $client_id user $user"); + + return (OK, $client_id, $user); +} + +sub authenticate { + my ($self) = @_; + + my ($status, $client, $login) = $self->_authenticate('authenticate'); + + return $status; +} + +sub can_change_password { + return 0; +} + +sub requires_cleartext_password { + return 0; +} + +sub change_password { + return ERR_BACKEND; +} + +sub verify_config { + my $self = shift; + my $cfg = $self->{config}; + + if (!$cfg) { + die 'config/kivitendo.conf: Key "authentication/http_headers" is missing.'; + } + + foreach (@required_config_options) { + next if $cfg->{$_}; + die 'config/kivitendo.conf: Missing parameter in "authentication/http_headers": ' . $_; + } +} + +=pod + +=encoding utf8 + +=head1 NAME + +SL::Auth::HTTPHeaders - Automatically log in users based on headers +sent by upstream servers + +=head1 OVERVIEW + +This module implements two modes for automatic log in for users: + +=over 4 + +=item HTTP Basic Authentication + +=item passing user name & client ID via arbitrary headers + +=back + +The module must be enabled in the configuration file by setting +C. It is then configured by the +sections C & C. + +=head1 SUPPORTED AUTHENTICATION METHODS + +=head2 User name & client ID in HTTP headers + +Must be enabled by setting +C. If enabled, it relies on +upstream servers (web server, proxy server) doing the authentication +with SSO solutions like Authelia & Authentik. These solutions must +then send the user name of the authenticated user in an HTTP header & +the desired client ID in another header. + +In order to ensure no malicious third party can simply set these +header values, a shared secret must be configured in the configuration +file & sent along in a third header field. + +The names of all three headers as well as the shared secret must be +set in the configuration file's C +section. + +This mode is mutually exclusive with the HTTP Basic Authentication +mentioned below. + +=head2 HTTP Basic Authentication (RFC 7617) + +Must be enabled by setting C. If +enabled, it relies on the web server doing the authentication for it & +passing the result in the C header, which turns into e +environment variable C according to the CGI +specifications. + +This mode only supports using the default client as no way to pass the +desired client ID has been implemented yet. + +This mode is mutually exclusive with the "User name & client ID in +HTTP headers" mode mentioned above. + +=head1 AUTHOR + +Moritz Bunkus Em.bunkus@linet.deE + +=cut + +1; diff --git a/SL/BackgroundJob/CheckBelowMinimumStock.pm b/SL/BackgroundJob/CheckBelowMinimumStock.pm new file mode 100644 index 000000000..17d88a056 --- /dev/null +++ b/SL/BackgroundJob/CheckBelowMinimumStock.pm @@ -0,0 +1,164 @@ +package SL::BackgroundJob::CheckBelowMinimumStock; + +use strict; +use warnings; + +use parent qw(SL::BackgroundJob::Base); + +use SL::Mailer; +use SL::DB::Part; +use SL::Presenter::Part qw(part); +use SL::Presenter::Tag qw(html_tag link_tag); +use SL::Presenter::EscapedText qw(escape); +use SL::DBUtils qw(selectall_hashref_query); +use SL::Locale::String qw(t8); + +sub check_below_minimum_stock { + my ($self) = @_; + + my $dbh = SL::DB->client->dbh; + my $query = <{partnumber} + . "\t- " . $part_hash->{description} + . "\t- " . $part_hash->{onhand} + . "\t- " . $part_hash->{rop} + . "\n"; + push @ids, $part_hash->{id}; + } + $self->{errors} = $error_string; + $self->{ids} = \@ids; + } + return; +} + +sub _email_user { + my ($self) = @_; + return unless ($self->{config} && $self->{config}->{send_email_to}); + SL::DB::Manager::AuthUser->find_by(login => $self->{config}->{send_email_to})->get_config_value('email'); +} + + +sub send_email { + my ($self) = @_; + + my $email = $self->{job_obj}->data_as_hash->{mail_to} || $self->_email_user || undef; + return unless $email; + + # additional email + $email .= " " . $self->{job_obj}->data_as_hash->{email} if $self->{job_obj}->data_as_hash->{email} =~ m/(\S+)@(\S+)$/; + + my ($output, $content_type) = $self->_prepare_report; + + my $mail = Mailer->new; + $mail->{from} = $self->{config}->{email_from}; + $mail->{to} = $email; + $mail->{subject} = $self->{config}->{email_subject}; + $mail->{content_type} = $content_type; + $mail->{message} = $$output; + + my $err = $mail->send; + + if ($err) { + $self->{errors} .= t8('Mailer error #1', $err); + } + + return +} + +sub _prepare_report { + my ($self) = @_; + + my $template = Template->new({ 'INTERPOLATE' => 0, + 'EVAL_PERL' => 0, + 'ABSOLUTE' => 1, + 'CACHE_SIZE' => 0, + }); + + return unless $template; + my $email_template = $self->{config}->{email_template}; + my $filename = $email_template || ( (SL::DB::Default->get->templates || "templates/mails") . "/below_minimum_stock/error_email.html" ); + my $content_type = $filename =~ m/.html$/ ? 'text/html' : 'text/plain'; + + my @ids = @{ $self->{ids} }; + my @parts = @{ SL::DB::Manager::Part->get_all(where => [id => \@ids]) }; + + + my $table_head = html_tag('tr', + html_tag('th', t8('Partnumber')) . + html_tag('th', t8('ROP')) . + html_tag('th', t8('Onhand')) + ); + + my $table_body; + + $table_body .= html_tag('tr', $_ ) for + map { + html_tag('td', + link_tag( + $ENV{HTTP_ORIGIN} . $ENV{REQUEST_URI} + . '?action=Part/edit' + . '&part.id=' . escape($_->id) + # text + , $_->partnumber + ) + ). + html_tag('td', $_->rop). + html_tag('td', $_->onhand) + } @parts; + + my %params = ( + SELF => $self, + PARTS => \@parts, + part_table => html_tag('table', $table_head . $table_body), + ); + + my $output; + $template->process($filename, \%params, \$output) || die $template->error; + + return (\$output, $content_type); +} + +sub run { + my ($self, $job_obj) = @_; + $self->{job_obj} = $job_obj; + + $self->{config} = $::lx_office_conf{check_below_minimum_stock} || {}; + + $self->check_below_minimum_stock(); + + if ($self->{errors}) { + # on error we have to inform the user + $self->send_email(); + die $self->{errors}; + } + + return ; +} + +1; + +__END__ + +=head1 NAME + +SL::BackgroundJob::CheckMinimumStock - Checks for all parts if on hand is greater +as minimum stock (onhand > rop) + +=head1 SYNOPSIS + + use SL::BackgroundJob::CheckMinimumStock; + SL::BackgroundJob::CheckMinimumStock->new->run;; + +=cut diff --git a/SL/BackgroundJob/CleanUpEmailSubfolders.pm b/SL/BackgroundJob/CleanUpEmailSubfolders.pm new file mode 100644 index 000000000..3296bd8ec --- /dev/null +++ b/SL/BackgroundJob/CleanUpEmailSubfolders.pm @@ -0,0 +1,58 @@ +package SL::BackgroundJob::CleanUpEmailSubfolders; + +use strict; +use warnings; + +use parent qw(SL::BackgroundJob::Base); + +use SL::IMAPClient; + +sub clean_up_record_subfolders { + my ($self) = @_; + my $imap_client = SL::IMAPClient->new(%{$::lx_office_conf{imap_client}}); + + my $open_sales_orders = SL::DB::Manager::Order->get_all( + query => [ + vendor_id => undef, + closed => 0, + ], + ); + + $imap_client->clean_up_record_subfolders(active_records => $open_sales_orders); +} + +sub run { + my ($self, $job_obj) = @_; + $self->{job_obj} = $job_obj; + + $self->clean_up_record_subfolders(); + + return; +} + +1; + +__END__ + +=encoding utf8 + +=head1 NAME + +SL::BackgroundJob::CleanUpEmailSubfolders - Background job for removing all email +subfolders except open records. + +=head1 SYNOPSIS + +This background job syncs all emails in subfolders and adds emails files to the +corresponding record. After that is removes all subfolders except for open +records. + +=head1 BUGS + +Nothing here yet. + +=head1 AUTHOR + +Tamino Steinert Etamino.steinert@tamino.stE + +=cut diff --git a/SL/BackgroundJob/CloseProjectsBelongingToClosedSalesOrders.pm b/SL/BackgroundJob/CloseProjectsBelongingToClosedSalesOrders.pm index feb887a74..358a77ffe 100644 --- a/SL/BackgroundJob/CloseProjectsBelongingToClosedSalesOrders.pm +++ b/SL/BackgroundJob/CloseProjectsBelongingToClosedSalesOrders.pm @@ -24,8 +24,7 @@ sub run { SELECT oe.globalproject_id FROM oe WHERE (oe.globalproject_id IS NOT NULL) - AND (oe.customer_id IS NOT NULL) - AND NOT COALESCE(oe.quotation, FALSE) + AND oe.record_type = 'sales_order' AND COALESCE(oe.closed, FALSE) ) EOSQL diff --git a/SL/BackgroundJob/CloseQuotations.pm b/SL/BackgroundJob/CloseQuotations.pm new file mode 100644 index 000000000..9e95484b5 --- /dev/null +++ b/SL/BackgroundJob/CloseQuotations.pm @@ -0,0 +1,70 @@ +package SL::BackgroundJob::CloseQuotations; + +use strict; + +use parent qw(SL::BackgroundJob::Base); + +use SL::DB::Manager::Order; +use SL::DB::Order::TypeData qw(:types); +use SL::Locale::String qw(t8); + + +sub run { + my ($self, $job_obj) = @_; + $self->{job_obj} = $job_obj; + my $data = $job_obj->data_as_hash; + + my $dry_run = ($data->{dry_run}) ? 1 : 0; + my $today = DateTime->today; + my $years = $data->{years} // 1; + my $end = $today->subtract(years => $years); + + my $quotations = SL::DB::Manager::Order->get_all(where => [ + record_type => [ REQUEST_QUOTATION_TYPE(), SALES_QUOTATION_TYPE() ], + transdate => { le => $end }, + or => [ closed => 0, closed => undef], + ]); + + my (@req_quos, @sal_quos); + + my %dispatch = ( + REQUEST_QUOTATION_TYPE() => \@req_quos, + SALES_QUOTATION_TYPE() => \@sal_quos, + ); + + foreach my $quotation (@{ $quotations }) { + push @{ $dispatch{$quotation->record_type} }, $quotation->quonumber; + + next if $dry_run; + + $quotation->closed(1); + $quotation->save(); + } + + return $dry_run + ? t8('Request quotations not yet closed: #1 Sales quotations not yet closed: #2', + join(', ', @req_quos), join(', ', @sal_quos)) + : t8('Request quotations closed: #1 Sales quotations closed: #2', + join(', ', @req_quos), join(', ', @sal_quos)); +} + +1; + +__END__ + +=encoding utf8 + +=head1 NAME + +SL::BackgroundJob::CloseQuotations — +Background job for closing all request and sales quotations older than a given number of years + +=head1 SYNOPSIS + +=head1 AUTHOR + +Niklas Schmidt + + +=cut + diff --git a/SL/BackgroundJob/ConvertTimeRecordings.pm b/SL/BackgroundJob/ConvertTimeRecordings.pm index 45ded55e8..8f90d40b9 100644 --- a/SL/BackgroundJob/ConvertTimeRecordings.pm +++ b/SL/BackgroundJob/ConvertTimeRecordings.pm @@ -235,24 +235,6 @@ sub convert_with_linking { $do->save; $_->update_attributes(booked => 1) for @{$time_recordings_by_order_id->{$related_order_id}}; - $related_order->link_to_record($do); - - # TODO extend link_to_record for items, otherwise long-term no d.r.y. - foreach my $item (@{ $do->items }) { - foreach (qw(orderitems)) { - if ($item->{"converted_from_${_}_id"}) { - die unless $item->{id}; - RecordLinks->create_links('mode' => 'ids', - 'from_table' => $_, - 'from_ids' => $item->{"converted_from_${_}_id"}, - 'to_table' => 'delivery_order_items', - 'to_id' => $item->{id}, - ) || die; - delete $item->{"converted_from_${_}_id"}; - } - } - } - # update delivered and item's ship for related order my $helper = SL::Helper::ShippedQty->new->calculate($related_order)->write_to_objects; $related_order->delivered($related_order->{delivered}); @@ -305,7 +287,7 @@ sub get_order_for_time_recording { } $orders = SL::DB::Manager::Order->get_all(where => [customer_id => $tr->customer_id, - or => [quotation => undef, quotation => 0], + record_type => 'sales_order', globalproject_id => $project_id, ], with_objects => ['orderitems']); diff --git a/SL/BackgroundJob/CreatePeriodicInvoices.pm b/SL/BackgroundJob/CreatePeriodicInvoices.pm index 4e3206317..7384d8c1b 100644 --- a/SL/BackgroundJob/CreatePeriodicInvoices.pm +++ b/SL/BackgroundJob/CreatePeriodicInvoices.pm @@ -106,7 +106,9 @@ sub _log_msg { sub _generate_time_period_variables { my $config = shift; my $period_start_date = shift; - my $period_end_date = $period_start_date->clone->add(months => $config->get_billing_period_length)->subtract(days => 1); + + my $period_length = $config->periodicity eq 'o' ? $config->get_order_value_period_length : $config->get_billing_period_length; + my $period_end_date = $period_start_date->clone->add(months => $period_length)->subtract(days => 1); my @month_names = ('', $::locale->text('January'), $::locale->text('February'), $::locale->text('March'), $::locale->text('April'), $::locale->text('May'), $::locale->text('June'), @@ -227,7 +229,7 @@ sub _create_periodic_invoice { if (!$self->{db_obj}->db->with_transaction(sub { 1; # make Emacs happy - $invoice = SL::DB::Invoice->new_from($order); + $invoice = SL::DB::Invoice->new_from($order, honor_recurring_billing_mode => 1); my $tax_point = ($invoice->tax_point // $time_period_vars->{period_end_date}->[0])->clone; @@ -255,21 +257,8 @@ sub _create_periodic_invoice { $invoice->post(ar_id => $config->ar_chart_id) || die; - $order->link_to_record($invoice); - - foreach my $item (@{ $invoice->items }) { - foreach (qw(orderitems)) { # expand if needed (delivery_order_items) - if ($item->{"converted_from_${_}_id"}) { - die unless $item->{id}; - RecordLinks->create_links('mode' => 'ids', - 'from_table' => $_, - 'from_ids' => $item->{"converted_from_${_}_id"}, - 'to_table' => 'invoice', - 'to_id' => $item->{id}, - ) || die; - delete $item->{"converted_from_${_}_id"}; - } - } + foreach my $item (grep { ($_->recurring_billing_mode eq 'once') && !$_->recurring_billing_invoice_id } @{ $order->orderitems }) { + $item->update_attributes(recurring_billing_invoice_id => $invoice->id); } SL::DB::PeriodicInvoice->new(config_id => $config->id, diff --git a/SL/BackgroundJob/ImportRecordEmails.pm b/SL/BackgroundJob/ImportRecordEmails.pm new file mode 100644 index 000000000..4b1614a95 --- /dev/null +++ b/SL/BackgroundJob/ImportRecordEmails.pm @@ -0,0 +1,294 @@ +package SL::BackgroundJob::ImportRecordEmails; + +use strict; +use warnings; + +use parent qw(SL::BackgroundJob::Base); + +use SL::IMAPClient; +use SL::DB::EmailJournal; +use SL::DB::Manager::EmailImport; +use SL::Helper::EmailProcessing; +use SL::Presenter::Tag qw(link_tag); +use SL::Locale::String qw(t8); + +use List::MoreUtils qw(any); +use Params::Validate qw(:all); +use Try::Tiny; + +sub sync_record_email_folder { + my ($self, $config) = @_; + + my %imap_config; + foreach my $key (qw(enabled hostname port ssl username password)) { + if (defined $config->{$key}) { + $imap_config{$key} = $config->{$key}; + } + } + + my $imap_client = SL::IMAPClient->new(%imap_config); + + my $email_import = $imap_client->update_emails_from_folder( + folder => $config->{folder}, + email_journal_params => { + record_type => $config->{record_type}, + } + ); + return "No emails to import." unless $email_import; + if ($config->{imported_imap_flag}) { + foreach my $email_journal (@{$email_import->email_journals}) { + $imap_client->set_flag_for_email( + email_journal => $email_journal, + flag => $config->{imported_imap_flag}, + ); + } + } + my $result = "Created email import with id " . $email_import->id . " for ". scalar @{ $email_import->email_journals } . " emails."; + + if ($config->{process_imported_emails}) { + my @function_names = + ref $config->{process_imported_emails} eq 'ARRAY' ? + @{$config->{process_imported_emails}} + : ($config->{process_imported_emails}); + foreach my $email_journal (@{$email_import->email_journals}) { + my $created_records = 0; + foreach my $function_name (@function_names) { + eval { + my $processed = SL::Helper::EmailProcessing->process_attachments($function_name, $email_journal); + $created_records += $processed; + 1; + } or do { + # # TODO: link not shown as link + # my $email_journal_link = link_tag( + # $ENV{HTTP_ORIGIN} . $ENV{REQUEST_URI} + # . '?action=EmailJournal/show' + # . '&id=' . $email_journal->id + # # text + # , $email_journal->id + # ); + my $email_journal_id = $email_journal->id; + $result .= t8("Error while processing email journal ('#1') attachments with '#2': ", $email_journal_id, $function_name) . $@ . "."; + }; + } + if ($created_records && $config->{processed_imap_flag}) { + $imap_client->set_flag_for_email( + email_journal => $email_journal, + flag => $config->{processed_imap_flag}, + ); + } elsif ($config->{not_processed_imap_flag}) { + $imap_client->set_flag_for_email( + email_journal => $email_journal, + flag => $config->{not_processed_imap_flag}, + ); + } + } + $result .= "Processed attachments with " + . join(', ', @function_names) . "." + if scalar @function_names; + } + + return $result; +} + +sub delete_email_imports { + my ($self, $email_import_ids_to_delete) = @_; + + my @not_found_email_import_ids; + my @deleted_email_import_ids; + foreach my $email_import_id (@$email_import_ids_to_delete) { + my $email_import = SL::DB::Manager::EmailImport->find_by(id => $email_import_id); + unless ($email_import) { + push @not_found_email_import_ids, $email_import_id; + next; + } + $email_import->delete(cascade => 1); + push @deleted_email_import_ids, $email_import_id; + } + + my $result = ""; + + $result .= t8("Deleted email import(s): ") + . join(', ', @deleted_email_import_ids) . "." + if scalar @deleted_email_import_ids; + + $result .= t8("Could not find email import(s): ") + . join(', ', @not_found_email_import_ids) . " for deletion." + if scalar @not_found_email_import_ids; + + return $result; +} + +sub run { + my ($self, $job_obj) = @_; + $self->{job_obj} = $job_obj; + + my $data; + + try { + $data = $job_obj->data_as_hash; + } catch { die t8("Invalid YAML Configuration for this job. Reason: malformed YAML Data: #1. Please consult: Program -> Documentation -> HTML -> Configuration of Background-Jobs.", $_ ); }; + + my @config_params = %{$data}; + + my %config = validate_with( + params => \@config_params, + spec => { + folder => { + type => SCALAR, + optional => 1, + }, + record_type => { + optional => 1, + default => 'catch_all', + callbacks => { + 'valid record type' => sub { + my $valid_record_types = SL::DB::EmailJournal->meta->{columns}->{record_type}->{check_in}; + unless (any {$_[0] eq $_} @$valid_record_types) { + die "record_type '$_[0]' is not valid. Possible values:\n- " . join("\n- ", @$valid_record_types); + } + }, + }, + }, + process_imported_emails => { + type => SCALAR | ARRAYREF, + optional => 1, + callbacks => { + 'function is implemented' => sub { + foreach my $function_name (ref $_[0] eq 'ARRAY' ? @{$_[0]} : ($_[0])) { + !!SL::Helper::EmailProcessing->can_function($function_name) or + die "Function '$function_name' not implemented in SL::Helper::EmailProcessing"; + } + 1; + } + } + }, + processed_imap_flag => { type => SCALAR, optional => 1, }, + not_processed_imap_flag => { type => SCALAR, optional => 1, }, + email_import_ids_to_delete => { type => ARRAYREF, optional => 1, }, + imported_imap_flag => { type => SCALAR, optional => 1, }, + # email config + hostname => { type => SCALAR, }, + port => { type => SCALAR, optional => 1}, + ssl => { type => BOOLEAN, default => 1}, + username => { type => SCALAR, }, + password => { type => SCALAR, }, + base_folder => { type => SCALAR, optional => 1}, + + }, + called => "YAML Configuration for this Background Job invalid. Please consult: Program -> Documentation -> HTML -> Configuration of Background-Jobs.", + ); + + my @results; + if (scalar $config{email_import_ids_to_delete}) { + push @results, $self->delete_email_imports($config{email_import_ids_to_delete}); + } + + push @results, $self->sync_record_email_folder(\%config); + + return join("\n", grep { $_ ne ''} @results); +} + +1; + +__END__ + +=encoding utf8 + +=head1 NAME + +SL::BackgroundJob::ImportPurchaseInvoiceEmails - Background job for syncing +emails from a folder for records. + +=head1 SYNOPSIS + +This background job imports emails from an imap folder. The emails are +imported into the email journal and can be processed with functions from +SL::Helper::EmailProcessing. + +=head1 CONFIGURATION + +The data field in the backgroundjob config contains all configuration values: + +=over 4 + +=item hostname + +required, hostname of IMAP server + +=item username + +required, login for IMAP server + +=item password + +required, password for login of IMAP server + +=item port + +optional Parameter IMAP port + +=item folder + +required, The IMAP folder to import emails from. Sub folders are separated by a forward slash, +e.g. 'INBOX/Archive'. Subfolders are not synced. Default is 'INBOX'. + +=item record_type + +optional, The record type to set for each imported email in the email journal. +Default is catch-all. Valid types are the well-known types of kivitendo records, ie ar_transaction, ap_transaction + +=item process_imported_emails + +optional, more processing can be automatically done in the job. +Valid actions are defined in SL::Helper::EmailProcessing.pm + +Take a look at currently supported actions with + + perldoc SL/Helper/EmailProcessing.pm + + +=item processed_imap_flag + +Optional, requires a valid value in process_imported_emails + +If process_imported_emails is set and the process is successfully +executed this custom IMAP Flag is added to the processed email. + +=item not_processed_imap_flag + +Optional, requires a valid value in process_imported_emails + +If process_imported_emails is set and the process is NOT +successfully executed this custom IMAP Flag is added +to the processed email. + +=item imported_imap_flag + +Optional + +If the import is successfully executed this custom IMAP Flag +is added to the imported email. + + +=back + +=head1 YAML Configuration example with ZUGFeRD Processing + + hostname: meinedomain.de + username: eingangsrechnung@meinedomain.de + password: secret + folder: INBOX/vollimport + record_type: ap_transaction + process_imported_emails: zugferd + processed_imap_flag: $Label8 + not_processed_imap_flag: $Label1 + +=head1 BUGS + +Nothing here yet. + +=head1 AUTHOR + +Tamino Steinert Etamino.steinert@tamino.stE + +=cut diff --git a/SL/BackgroundJob/InventoryClearAll.pm b/SL/BackgroundJob/InventoryClearAll.pm new file mode 100644 index 000000000..06cbe20a4 --- /dev/null +++ b/SL/BackgroundJob/InventoryClearAll.pm @@ -0,0 +1,91 @@ +package SL::BackgroundJob::InventoryClearAll; + +use strict; + +use parent qw(SL::BackgroundJob::Base); + +use SL::DB::Inventory; +use SL::Helper::Inventory qw(:ALL); +use SL::Locale::String qw(t8); + + +sub run { + my ($self, $job_obj) = @_; + my $data = $job_obj->data_as_hash; + + my $comment = $data->{comment} // 'vor Inventur'; + my $date = DateTime->today_local(); + my $date_str = $date->format_cldr('yyyy-MM-dd'); + my $dry_run = ($data->{dry_run}) ? 1 : 0; + my $employee_id = SL::DB::Manager::Employee->current->id; + my $trans_type_in = SL::DB::Manager::TransferType->find_by(description => 'correction', direction => 'in'); + my $trans_type_out = SL::DB::Manager::TransferType->find_by(description => 'correction', direction => 'out'); + my $warehouse_id; + + if (exists $data->{warehouse}) { + $warehouse_id = SL::DB::Manager::Warehouse->find_by(description => $data->{warehouse}); + die "Lager existiert nicht: $data->{warehouse}" if !defined $warehouse_id; + } + + die "No parameter correction_date given" + if !exists $data->{correction_date}; + die "Parameter correction_date $data->{correction_date} is not today $date_str" + if $data->{correction_date} ne $date_str; + + my $stock_all = get_stock(warehouse => $warehouse_id, + by => [ qw(bin chargenumber part) ], + with_objects => [ qw(part) ]); + my @stock = grep { $_->{qty} != 0 } @{ $stock_all }; + my $ntransactions = scalar @stock; + + my @trans; + foreach (@stock) { + my $qty = $_->{qty} * -1; + + push @trans, "$_->{part}->{id} $qty $_->{part}->{unit}"; + + next if $dry_run; + my $x = SL::DB::Inventory->new(); + + $x->bestbefore ($_->{bestbefore}); + $x->bin_id ($_->{bin_id}); + $x->chargenumber($_->{chargenumber}); + $x->comment ($comment); + $x->employee_id ($employee_id); + $x->parts_id ($_->{parts_id}); + $x->qty ($qty); + $x->shippingdate($date); + $x->trans_type (($qty > 0) ? $trans_type_in : $trans_type_out); + $x->warehouse_id($_->{warehouse_id}); + + $x->save(); + } + + return $dry_run + ? t8('Inventory: #1 transactions not yet executed to clear all inventory slots. Parts: #2', + $ntransactions, join(',', @trans)) + : t8('Inventory: #1 transactions executed to clear all inventory slots. Parts: #2', + $ntransactions, join(',', @trans)); +} + +1; + + +__END__ + +=encoding utf8 + +=head1 NAME + +SL::BackgroundJob::InventoryClearAll — +Background job which performs all inventory transactions needed to empty all +warehoue bins. Useful before stocktaking + +=head1 SYNOPSIS + +=head1 AUTHOR + +Niklas Schmidt + + +=cut diff --git a/SL/BackgroundJob/MassRecordCreationAndPrinting.pm b/SL/BackgroundJob/MassRecordCreationAndPrinting.pm index 4cc2519d5..9e0c88d89 100644 --- a/SL/BackgroundJob/MassRecordCreationAndPrinting.pm +++ b/SL/BackgroundJob/MassRecordCreationAndPrinting.pm @@ -53,6 +53,16 @@ sub create_invoices { eval { my $sales_delivery_order = SL::DB::DeliveryOrder->new(id => $delivery_order_id)->load; $number = $sales_delivery_order->donumber; + + # Only process open delivery orders. In this list should only be open + # delivery orders, but if the background job is restarted (for what + # reason so ever), a new creation of invoices for delivery orders which + # are closed now can be triggered. + # Prevent this. + if ($sales_delivery_order->closed) { + die "Delivery Order already closed!\n"; + } + my %conversion_params = $data->{transdate} ? ('attributes' => { transdate => $data->{transdate} }) : (); my $invoice = $sales_delivery_order->convert_to_invoice(%conversion_params); diff --git a/SL/BackgroundJob/RemoveInvalidFileEntries.pm b/SL/BackgroundJob/RemoveInvalidFileEntries.pm new file mode 100644 index 000000000..12cff1ca9 --- /dev/null +++ b/SL/BackgroundJob/RemoveInvalidFileEntries.pm @@ -0,0 +1,51 @@ +package SL::BackgroundJob::RemoveInvalidFileEntries; + +use strict; +use warnings; + +use parent qw(SL::BackgroundJob::Base); + +use SL::File; + +use constant WAITING_FOR_EXECUTION => 0; +use constant SCAN_START => 1; +use constant DONE => 2; + +# Data format: +# my $data = { +# file_errors = [ +# "Ich bin ein Fehler", +# ], +# } + +sub scan_file_entry { + my ($self) = @_; + my $job_obj = $self->{job_obj}; + $job_obj->set_data(status => SCAN_START())->save; + + my @file_entries = @{ SL::DB::Manager::File->get_all() }; + + my @files = map { SL::File::Object->new(db_file => $_, id => $_->id, loaded => 1) } @file_entries; + + my $data = $job_obj->data_as_hash; + foreach my $file (@files) { + unless (eval {$file->get_file()}) { + #warn $@; + push(@{$data->{file_errors}}, $@); + $job_obj->update_attributes(data_as_hash => $data); + $file->loaded_db_file->delete(); + } + } +} + +sub run { + my ($self, $job_obj) = @_; + $self->{job_obj} = $job_obj; + + $self->scan_file_entry(); + + $job_obj->set_data(status => DONE())->save; + return 1; +} + +1; diff --git a/SL/BackgroundJob/SelfTest.pm b/SL/BackgroundJob/SelfTest.pm index b715f9e07..f9ea74dcf 100644 --- a/SL/BackgroundJob/SelfTest.pm +++ b/SL/BackgroundJob/SelfTest.pm @@ -26,7 +26,7 @@ use Rose::Object::MakeMethods::Generic ( 'add_full_diag' => { interface => 'add', hash_key => 'full_diag' }, ], scalar => [ - qw(diag tester config aggreg module_nr), + qw(diag tester config aggreg module_nr additional_email), ], ); @@ -44,14 +44,22 @@ sub setup { $self->aggreg(TAP::Parser::Aggregator->new); $self->modules(split /\s+/, $self->config->{modules}); + $self->modules($self->{options}->{modules}) if $self->{options}->{modules}; } sub run { - my $self = shift; + my $self = shift; + my $db_obj = shift; + + # get custom options (module list || alternate email) + $self->{options} = $db_obj->data_as_hash; $self->setup; return 1 unless $self->modules; + # set additional mail + $self->additional_email($self->{options}->{email}) if $self->{options}->{email} =~ m/(\S+)@(\S+)$/; + foreach my $module ($self->modules) { $self->run_module($module); } @@ -124,10 +132,13 @@ sub _send_email { return if !$self->config || !$self->config->{send_email_to}; my $user = $self->_email_user; - my $email = $user ? $user->get_config_value('email') : undef; - + my $email = $self->{options}->{mail_to} ? $self->{options}->{mail_to} + : $user ? $user->get_config_value('email') + : undef; return unless $email; + $email .= $self->additional_email ? ',' . $self->additional_email : ''; + my ($output, $content_type) = $self->_prepare_report; my $mail = Mailer->new; @@ -138,7 +149,7 @@ sub _send_email { $mail->{message} = $$output; my $err = $mail->send; - $self->add_errors('Mailer error #1', $err) if $err; + $self->add_errors($::locale->text('Mailer error #1', $err)) if $err; } diff --git a/SL/BackgroundJob/SelfTest/NovoclonStrict.pm b/SL/BackgroundJob/SelfTest/NovoclonStrict.pm new file mode 100644 index 000000000..826567d94 --- /dev/null +++ b/SL/BackgroundJob/SelfTest/NovoclonStrict.pm @@ -0,0 +1,318 @@ +package SL::BackgroundJob::SelfTest::NovoclonStrict; + +use utf8; +use strict; +use parent qw(SL::BackgroundJob::SelfTest::Base); + +use DateTime; +use List::MoreUtils qw(none notall); +use SL::DB::DeliveryOrder; +use SL::DB::Order; +use SL::DB::PurchaseInvoice; + +use Rose::Object::MakeMethods::Generic ( + 'scalar --get_set_init' => [ qw(start_date) ], +); + +sub init_start_date { + DateTime->new(day => 1, month => 1, year => DateTime->today->year); +} + + +sub run { + my ($self) = @_; + + $self->tester->plan(tests => 8); + + $self->check_no_missing_invoices; + $self->check_no_missing_deliveries; + $self->check_no_missing_order_confirmations; + $self->check_invoices_mailed; + $self->check_order_confirmations_mailed; + $self->check_quotations_mailed; + $self->check_purchase_invoices_sums_against_purchase_orders; + $self->check_purchase_invoices_sums_against_sales_orders; +} + +sub check_no_missing_invoices { + my ($self) = @_; + + my $days_delta = 4; + my $title = "Alle Verkaufslieferscheine sind $days_delta Werktage nach Lieferterimin geschlossen."; + + my $latest_reqdate = DateTime->today_local->subtract_businessdays(days => $days_delta); + my $open_delivery_orders = SL::DB::Manager::DeliveryOrder->get_all_sorted(where => ['!customer_id' => undef, + '!cusordnumber' => { ilike => ['muster'] }, + delivered => 1, + or => [closed => undef, closed => 0], + reqdate => {le => $latest_reqdate}, + transdate => {ge => $self->start_date},] + ); + + if (@$open_delivery_orders) { + $self->tester->ok(0, $title); + $self->tester->diag("Folgende Verkaufslieferscheine sind geliefert und nach Liefertermin länger als $days_delta Werktage offen. Vermutlich fehlt die Rechnung:"); + $self->tester->diag("Lieferschein-Nummer: " . $_->donumber) for @$open_delivery_orders; + + } else { + $self->tester->ok(1, $title); + } +} + +sub check_no_missing_deliveries { + my ($self) = @_; + + my $days_delta = 2; + my $title = "Alle offenen Auftragsbestätigungen mit Liefertermin vor mindestens $days_delta Werktagen haben eine Lieferung."; + + my $latest_reqdate = DateTime->today_local->subtract_businessdays(days => $days_delta); + my $orders = SL::DB::Manager::Order->get_all_sorted(where => [record_type => 'sales_order', + or => [closed => undef, closed => 0], + reqdate => {le => $latest_reqdate}, + transdate => {ge => $self->start_date},]); + my %not_delivered; + foreach my $order (@$orders) { + my $lr = $order->linked_records(to => 'DeliveryOrder'); + $lr = [grep { !!$_->customer_id } @$lr]; + + if (scalar @$lr == 0) { + push @{ $not_delivered{no_delivery_order} }, $order->ordnumber; + next; + } + + if (none { $_->delivered } @$lr) { + push @{ $not_delivered{none_delivered} }, $order->ordnumber; + next; + } + + if (notall { $_->delivered } @$lr) { + push @{ $not_delivered{notall_delivered} }, $order->ordnumber; + next; + } + } + + if (@{ $not_delivered{no_delivery_order} || [] } || @{ $not_delivered{none_delivered} || [] } || @{ $not_delivered{notall_delivered} || [] }) { + $self->tester->ok(0, $title); + + if (@{ $not_delivered{no_delivery_order} || [] }) { + $self->tester->diag("Folgende offene fällige Auftragsbestätigungen haben keine Verkaufslieferscheine:"); + $self->tester->diag("Auftrags-Nummer: " . $_) for @{ $not_delivered{no_delivery_order} }; + } + if (@{ $not_delivered{none_delivered} || [] }) { + $self->tester->diag("Folgende offene fällige Auftragsbestätigungen haben Verkaufslieferscheine, von denen keine geliefert sind:"); + $self->tester->diag("Auftrags-Nummer: " . $_) for @{ $not_delivered{none_delivered} }; + } + if (@{ $not_delivered{notall_delivered} || [] }) { + $self->tester->diag("Folgende offene fällige Auftragsbestätigungen haben einen oder mehrere nicht gelieferte Verkaufslieferscheine:"); + $self->tester->diag("Auftrags-Nummer: " . $_) for @{ $not_delivered{notall_delivered} }; + } + + } else { + $self->tester->ok(1, $title); + } +} + +sub check_no_missing_order_confirmations { + my ($self) = @_; + + my $days_delta = 3; + my $title = "Alle offenen Auftragseingänge älter als $days_delta Werktage haben eine Auftragsbestätigung."; + + my $latest_transdate = DateTime->today_local->subtract_businessdays(days => $days_delta); + + my $orders = SL::DB::Manager::Order->get_all_sorted(where => [record_type => 'sales_order_intake', + or => [closed => undef, closed => 0], + transdate => {le => $latest_transdate}, + transdate => {ge => $self->start_date},]); + + # Check, if order confirmations are in the worklfow. + # (Maybe it is sufficient to list all order intakes which are not closed because + # they will be closed when an related order confirmation is created.) + my @not_confirmed_order_intakes; + foreach my $order (@$orders) { + my $lr = $order->linked_records(direction => 'to', recursive => 1); + $lr = [grep { 'SL::DB::Order' eq ref $_ && $_->is_type('sales_order') } @$lr]; + push @not_confirmed_order_intakes, $order->ordnumber if scalar @$lr == 0; + } + + if (@not_confirmed_order_intakes) { + $self->tester->ok(0, $title); + + $self->tester->diag("Folgende offene Auftragseingänge älter als $days_delta haben keine Auftragsbestätigung:"); + $self->tester->diag("Auftrageingangs-Nummer: " . $_) for @not_confirmed_order_intakes; + + } else { + $self->tester->ok(1, $title); + } + +} + +sub check_invoices_mailed { + my ($self) = @_; + + my $title = "Alle offenen Verkaufsrechnungen sind per Mail verschickt worden."; + + my $invoices = SL::DB::Manager::Invoice->get_all_sorted(where => [invoice => 1, + type => 'invoice', + or => [storno => undef, storno => 0], + transdate => {ge => $self->start_date},]); + $invoices = [grep { !$_->closed } @$invoices]; + + my @documents_not_mailed = $self->get_documents_not_mailed($invoices); + $self->complain_documtens_not_mailed( + \@documents_not_mailed, + main_title => $title, + sub_title => "Folgende offenen Verkaufsrechungen sind nicht per Mail verschickt worden", + nr_title => "Rechnungs-Nummer" + ); +} + +sub check_order_confirmations_mailed { + my ($self) = @_; + + my $days_delta = 1; + my $title = "Alle offenen Auftragsbestätigungen älter als $days_delta Werktage sind per Mail verschickt worden."; + + my $latest_transdate = DateTime->today_local->subtract_businessdays(days => $days_delta); + + my $orders = SL::DB::Manager::Order->get_all_sorted(where => [record_type => 'sales_order', + or => [closed => undef, closed => 0], + transdate => {le => $latest_transdate}, + transdate => {ge => $self->start_date},]); + + my @documents_not_mailed = $self->get_documents_not_mailed($orders); + $self->complain_documtens_not_mailed( + \@documents_not_mailed, + main_title => $title, + sub_title => "Folgende offenen Auftragsbestätigungen älter als $days_delta Werktage sind nicht per Mail verschickt worden", + nr_title => "Auftrags-Nummer" + ); +} + +sub check_quotations_mailed { + my ($self) = @_; + + my $days_delta = 3; + my $title = "Alle offenen Angebote älter als $days_delta Werktage sind per Mail verschickt worden."; + + my $latest_transdate = DateTime->today_local->subtract_businessdays(days => $days_delta); + + my $orders = SL::DB::Manager::Order->get_all_sorted(where => [record_type => 'request_quotion', + or => [closed => undef, closed => 0], + transdate => {le => $latest_transdate}, + transdate => {ge => $self->start_date},]); + + my @documents_not_mailed = $self->get_documents_not_mailed($orders); + $self->complain_documtens_not_mailed( + \@documents_not_mailed, + main_title => $title, + sub_title => "Folgende offenen Angebote älter als $days_delta Werktage sind nicht per Mail verschickt worden", + nr_title => "Angebots-Nummer" + ); +} + +sub get_documents_not_mailed { + my ($self, $objects) = @_; + + my @documents_not_mailed; + foreach my $object (@$objects) { + my $mails = $object->linked_records(to => 'EmailJournal'); + push @documents_not_mailed, $object->record_number if scalar @$mails == 0; + } + + return @documents_not_mailed; +} + +sub complain_documtens_not_mailed { + my ($self, $documents_not_mailed, %params) = @_; + + my $main_title = $params{main_title} | ''; + my $sub_title = $params{sub_title} | ''; + my $nr_title = $params{nr_title} | ''; + + if (@{ $documents_not_mailed || [] }) { + $self->tester->ok(0, $main_title); + + $self->tester->diag($sub_title . ":"); + $self->tester->diag($nr_title . ": " . $_) for @$documents_not_mailed; + + } else { + $self->tester->ok(1, $main_title); + } +} + +# Check for all purchase invoices if the sum of all related purchase +# orders is greater than the sum of all related purchase invoices. +sub check_purchase_invoices_sums_against_purchase_orders { + my ($self) = @_; + + my $title = "Die Netto-Summe der Einkaufsrechnungen ist kleiner oder gleich der Netto-Summe der Lieferantenaufträge."; + + my $purchase_invoices = SL::DB::Manager::PurchaseInvoice->get_all_sorted(where => ['!storno' => 1, + invoice => 1, + transdate => {ge => $self->start_date},]); + + my @purchase_invoices_with_wrong_sums; + foreach my $purchase_invoice (@$purchase_invoices) { + if (!$purchase_invoice->check_sums_against_purchase_orders($purchase_invoice)) { + push @purchase_invoices_with_wrong_sums, $purchase_invoice; + } + } + + if (@purchase_invoices_with_wrong_sums) { + $self->tester->ok(0, $title); + $self->tester->diag("Folgende " . scalar @purchase_invoices_with_wrong_sums . " Einkaufsrechnungen ergeben eine viel höhere Netto-Summe alsergeben eine viel höhere Netto-Summe als ursprüngliche beauftragt:"); + $self->tester->diag("Einkaufsrechnungs-Nummer vom " . $_->transdate_as_date . ": " . $_->record_number) for @purchase_invoices_with_wrong_sums; + + } else { + $self->tester->ok(1, $title); + } +} + +# Check for all purchase invoices if the sum of all related sales +# orders is greater than the sum of all related purchase invoices. +sub check_purchase_invoices_sums_against_sales_orders { + my ($self) = @_; + + my $title = "Die Summe der Einkaufsrechnungen ist kleiner oder gleich der Summe der Auftragsbestätigungen."; + + my $purchase_invoices = SL::DB::Manager::PurchaseInvoice->get_all_sorted(where => ['!storno' => 1, + invoice => 1, + transdate => {ge => $self->start_date},]); + + my @purchase_invoices_with_wrong_sums; + foreach my $purchase_invoice (@$purchase_invoices) { + if (!$purchase_invoice->check_sums_against_sales_orders($purchase_invoice)) { + push @purchase_invoices_with_wrong_sums, $purchase_invoice; + } + } + + if (@purchase_invoices_with_wrong_sums) { + $self->tester->ok(0, $title); + $self->tester->diag("Folgende " . scalar @purchase_invoices_with_wrong_sums . " Einkaufsrechnungen haben eine zu hohe Summe:"); + $self->tester->diag("Einkaufsrechnungs vom " . $_->transdate_as_date . ": " . $_->record_number) for @purchase_invoices_with_wrong_sums; + + } else { + $self->tester->ok(1, $title); + } +} + +1; + +__END__ + +=encoding utf-8 + +=head1 NAME + +SL::BackgroundJob::SelfTest::NovoclonStrict - special tests novoclon + +=head1 DESCRIPTION + +Special tests for novoclon. + +=head1 AUTHOR + +Bernd Bleßmann Ebernd@kivitendo-premium.deE + +=cut diff --git a/SL/BackgroundJob/SelfTest/Transactions.pm b/SL/BackgroundJob/SelfTest/Transactions.pm index ab145cece..db0d13f77 100644 --- a/SL/BackgroundJob/SelfTest/Transactions.pm +++ b/SL/BackgroundJob/SelfTest/Transactions.pm @@ -165,7 +165,7 @@ sub check_verwaiste_invoice_eintraege { sub check_netamount_laut_invoice_ar { my ($self) = @_; my $query = qq| - select sum(round(cast(i.qty*(i.fxsellprice * (1-i.discount)) as numeric), 2)) + select sum(round(cast(i.qty* i.sellprice / COALESCE(price_factor, 1) as numeric), 2)) from invoice i left join ar a on (a.id = i.trans_id) where a.transdate >= ? and a.transdate <= ?;|; @@ -523,7 +523,7 @@ sub check_missing_tax_bookings { # check tax bookings. all taxkey <> 0 should have tax bookings in acc_trans my $query = qq| select trans_id, chart.accno,transdate from acc_trans left join chart on (chart.id = acc_trans.chart_id) - WHERE taxkey NOT IN (SELECT taxkey from tax where rate=0) AND trans_id NOT IN + WHERE taxkey NOT IN (SELECT taxkey from tax where rate=0 OR reverse_charge_chart_id is not null) AND trans_id NOT IN (select trans_id from acc_trans where chart_link ilike '%tax%' and trans_id IN (SELECT trans_id from acc_trans where taxkey NOT IN (SELECT taxkey from tax where rate=0))) AND transdate >= ? AND transdate <= ?|; @@ -570,7 +570,7 @@ sub check_ar_paid_acc_trans { my $query = qq| select sum(ac.amount) as paid_amount, ar.invnumber,ar.paid from acc_trans ac left join ar on (ac.trans_id = ar.id) - WHERE ac.chart_link like '%AR_paid%' + WHERE (ac.chart_link like '%AR_paid%' OR ac.fx_transaction) AND ac.trans_id in (SELECT trans_id from acc_trans ac where ac.transdate >= ? AND ac.transdate <= ?) group by invnumber, paid having sum(ac.amount) <> ar.paid*-1|; @@ -643,7 +643,7 @@ sub check_orphaned_reconciliated_links { my ($self) = @_; my $query = qq| - SELECT purpose from bank_transactions + SELECT id, purpose from bank_transactions WHERE cleared is true AND NOT EXISTS (SELECT bank_transaction_id from reconciliation_links WHERE bank_transaction_id = bank_transactions.id) AND transdate >= ? AND transdate <= ?|; @@ -654,7 +654,7 @@ sub check_orphaned_reconciliated_links { $self->tester->ok(0, "Verwaiste abgeglichene Bankbewegungen gefunden. Bei folgenden Bankbewegungen ist die abgleichende Verknüpfung gelöscht worden:"); for my $bt_orphaned (@{ $bt_cleared_no_link }) { - $self->tester->diag("Verwendungszweck: $bt_orphaned->{purpose}"); + $self->tester->diag("ID: $bt_orphaned->{id} Verwendungszweck: $bt_orphaned->{purpose}"); } } else { $self->tester->ok(1, "Keine verwaisten Einträge in abgeglichenen Bankbewegungen."); @@ -693,7 +693,7 @@ sub check_orphaned_bank_transaction_acc_trans_links { my ($self) = @_; my $query = qq| - SELECT purpose from bank_transactions + SELECT id, purpose from bank_transactions WHERE invoice_amount <> 0 AND NOT EXISTS (SELECT bank_transaction_id FROM bank_transaction_acc_trans WHERE bank_transaction_id = bank_transactions.id) AND itime > (SELECT min(itime) from bank_transaction_acc_trans) @@ -705,7 +705,7 @@ sub check_orphaned_bank_transaction_acc_trans_links { $self->tester->ok(0, "Verwaiste Verknüpfungen zu Bankbewegungen gefunden. Bei folgenden Bankbewegungen ist eine interne Verknüpfung gelöscht worden:"); for my $bt_orphaned (@{ $bt_assigned_no_link }) { - $self->tester->diag("Verwendungszweck: $bt_orphaned->{purpose}"); + $self->tester->diag("ID: $bt_orphaned->{id} Verwendungszweck: $bt_orphaned->{purpose}"); } } else { $self->tester->ok(1, "Keine verwaisten Einträge in verknüpften Bankbewegungen (Richtung Bank)."); diff --git a/SL/BackgroundJob/SendFollowUpReminder.pm b/SL/BackgroundJob/SendFollowUpReminder.pm new file mode 100644 index 000000000..0796695ed --- /dev/null +++ b/SL/BackgroundJob/SendFollowUpReminder.pm @@ -0,0 +1,322 @@ +package SL::BackgroundJob::SendFollowUpReminder; + +use strict; + +use parent qw(SL::BackgroundJob::Base); + +use SL::DB::AuthUser; +use SL::DB::FollowUp; +use SL::Locale::String qw(t8); +use SL::Mailer; +use SL::Presenter; +use SL::Util qw(trim); + +use DateTime; +use File::Slurp qw(slurp); +use List::Util qw(any); +use Try::Tiny; +use URI; + +sub create_job { + $_[0]->create_standard_job('7 6 1-5 * *'); # every workday at 06:07 +} + +use Rose::Object::MakeMethods::Generic ( + 'scalar' => [ qw(params) ], +); + +# +# If job does not throw an error, +# success in background_job_histories is 'success'. +# It is 'failure' otherwise. +# +# Return value goes to result in background_job_histories. +# +sub run { + my ($self, $db_obj) = @_; + + $self->{$_} = [] for qw(job_errors); + + $self->initialize_params($db_obj->data_as_hash) if $db_obj; + + my @follow_up_date_where = (); + push @follow_up_date_where, (follow_up_date => { ge => [ $self->params->{from_date} ]}) if $self->params->{from_date}; + push @follow_up_date_where, (follow_up_date => { le => [ $self->params->{to_date} ]}) if $self->params->{to_date}; + + my $follow_ups = SL::DB::Manager::FollowUp->get_all(where => ['done.id' => undef, + @follow_up_date_where, ], + with_objects => ['done'], + sort_by => ['follow_up_date']); + + # Collect follow ups for users with e-mail-address. + my $mail_to_by_employee_id; + foreach my $follow_up (@$follow_ups) { + + # add link + $follow_up->{link} = URI->new_abs('fu.pl?action=edit&id=' . $follow_up->id, $::form->_get_request_uri); + + foreach my $employee (@{ $follow_up->created_for_employees }) { + next if $employee->deleted; + + if (!exists $mail_to_by_employee_id->{$employee->id}) { + my $user = SL::DB::Manager::AuthUser->find_by(login => $employee->login); + if ($user) { + my $mail_to = trim($user->get_config_value('email')); + + next if !$mail_to; + + $mail_to_by_employee_id->{$employee->id}->{mail_to} = $mail_to; + } + } + + if (exists $mail_to_by_employee_id->{$employee->id}) { + push @{ $mail_to_by_employee_id->{$employee->id}->{follow_ups} }, $follow_up; + } + } + } + + foreach (keys %$mail_to_by_employee_id) { + my ($message, $content_type) = $self->generate_message($mail_to_by_employee_id->{$_}->{follow_ups}); + + my $mail = Mailer->new; + $mail->{from} = $self->params->{email_from}; + $mail->{to} = $mail_to_by_employee_id->{$_}->{mail_to}; + $mail->{bcc} = SL::DB::Default->get->global_bcc; + $mail->{subject} = $self->params->{email_subject}; + $mail->{message} = $message; + $mail->{content_type} = $content_type; + + my $error = $mail->send; + + if ($error) { + push @{ $self->{job_errors} }, $error; + } + } + + my $msg = t8('Follow ups reminder sent.'); + + # die if errors exists + if (@{ $self->{job_errors} }) { + $msg .= t8('The following errors occurred:'); + $msg .= join "\n", @{ $self->{job_errors} }; + die $msg . "\n"; + } + + return $msg; +} + +# helper +sub initialize_params { + my ($self, $data) = @_; + + # valid parameters with default values + my %valid_params = ( + from_date => undef, + to_date => DateTime->today_local->to_kivitendo, + email_from => $::lx_office_conf{follow_up_reminder}->{email_from}, + email_subject => $::lx_office_conf{follow_up_reminder}->{email_subject}, + email_template => $::lx_office_conf{follow_up_reminder}->{email_template}, + ); + + # check user input param names + foreach my $param (keys %$data) { + die "Not a valid parameter: $param" unless exists $valid_params{$param}; + } + + # set defaults + $self->params( + { map { ($_ => $data->{$_} // $valid_params{$_}) } keys %valid_params } + ); + + # convert date from string to object + my ($from_date, $to_date); + try { + if ($self->params->{from_date}) { + $from_date = DateTime->from_kivitendo($self->params->{from_date}); + # Not undef and no other type. + die unless ref $from_date eq 'DateTime'; + } + if ($self->params->{to_date}) { + $to_date = DateTime->from_kivitendo($self->params->{to_date}); + # Not undef and no other type. + die unless ref $to_date eq 'DateTime'; + } + } catch { + die t8("Cannot convert date.") ."\n" . + t8("Input from string: #1", $self->params->{from_date}) . "\n" . + t8("Input to string: #1", $self->params->{to_date}) . "\n" . + t8("Details: #1", $_); + }; + + $self->params->{from_date} = $from_date; + $self->params->{to_date} = $to_date; + + $self->params->{email_from} = trim($self->params->{email_from}); + die t8('No email sender address given.') if !$self->params->{email_from}; + + return $self->params; +} + +sub generate_message { + my ($self, $follow_ups) = @_; + + # Force scripts/locales.pl to parse this template: + # parse_html_template('fu/follow_up_reminder_mail') + my $email_template = $self->params->{email_template}; + my $template = 'fu/follow_up_reminder_mail'; + my $content_type = 'text/html'; + my $render_type = 'html'; + + if ($email_template) { + my $content; + try { + $content = slurp $email_template; + + } catch { + $::lxdebug->message(LXDebug->WARN(), 'Cannot read template file. Error: ' . $_); + }; + + return $self->generate_message_simple_text($follow_ups) if !$content; + + $template = \$content; + $content_type = $email_template =~ m/.html$/ ? 'text/html' : 'text/plain'; + $render_type = $email_template =~ m/.html$/ ? 'html' : 'text'; + } + + my $output = SL::Presenter->get->render($template, + {type => $render_type}, + follow_ups => $follow_ups); + + return ($output, $content_type); +} + +sub generate_message_simple_text { + my ($self, $follow_ups) = @_; + + my $output = t8('Unfinished follow-ups') . ":\n"; + foreach my $follow_up (@$follow_ups) { + $output .= t8('Follow-Up Date') . ': ' . $follow_up->follow_up_date_as_date; + $output .= ': ' . $follow_up->note->subject; + $output .= ': (' . t8('by') . ' ' . $follow_up->created_by_employee->safe_name . ')'; + $output .= ' (' . $follow_up->{link} . ')'; + $output .= "\n"; + } + + $output .= "\n\n"; + + return ($output, 'text/plain'); +} + +1; + +__END__ + +=pod + +=encoding utf8 + +=head1 NAME + +SL::BackgroundJob::SendFollowUpReminder - Send emails to employees to +remind them of due follow ups + +=head1 SYNOPSIS + +Get all due follow ups. This are the ones that are not done yet and have a +follow up date until today (configurable, see below). +For each employee addreesed by this follow ups, an email is send (if the +employees email address is configured). + +=head1 CONFIGURATION + +In the kivitendo configuration file (C) in the section +"follow_up_reminder" some settings can be made: + +=over 4 + +=item C + +The senders email address. This information can be overwriten by +data provided to the background job. + +=item C + +The subject of the emails. This information can be overwriten by +data provided to the background job. + +=item C + +A template file used to generate the emails content. It will be given an +array of the follow ups in the template variable C. +You can provide a text or a html template. +If not given, it defaults to C in the +webpages directory. +If given, but not found, a simple text version will be generated as +content. +This information can be overwriten by data provided to the background job. + +=back + +Also some data can be provided to configure this backgroung job. +If there is data provided and it cannot be validated the background job +fails. + +Example: + + from_date: 01.01.2022 + to_date: 01.07.2022 + email_subject: To-Do + +=over 4 + +=item C + +The date from which on follow ups not done yet should be collected. It defaults +undef which means from the beginning on. + +Example (format depends on your settings): + +from_date: 01.01.2022 + +=item C + +The date till which follow ups not done yet should be collected. It defaults +to today. + +Example (format depends on your settings): + +to_date: 01.07.2022 + +=item C + +The senders email address. It defaults to the one given in the kivitendo +configuration file (see above). This information must be provided some +how. + +Example: + +email_from: bernd@kivitendo.de + +=item C + +The subject of the emails. +It defaults to the one given in the kvitendo configuration file (see above). + +email_subject: To-Do + +=item C + +A template file used to generate the emails content. It will be given an +array of the follow ups in the template variable C. You can +provide a text or a html template. +It defaults to the one given in the kvitendo configuration file (see above). + +email_template: templates/my_templates/my_reminder_template.txt + +=back + +=head1 AUTHOR + +Bernd Bleßmann Ebernd@kivitendo-premium.deE + +=cut diff --git a/SL/BackgroundJob/SetBankAccountsMasterData.pm b/SL/BackgroundJob/SetBankAccountsMasterData.pm new file mode 100644 index 000000000..ffe40b730 --- /dev/null +++ b/SL/BackgroundJob/SetBankAccountsMasterData.pm @@ -0,0 +1,125 @@ +package SL::BackgroundJob::SetBankAccountsMasterData; + +use strict; + +use parent qw(SL::BackgroundJob::Base); + +use SL::DBUtils; + +sub run { + my ($self, $db_obj) = @_; + + my $data = $db_obj->data_as_hash; + + die "No valid integer for months" if $data->{months} && $data->{months} !~ /^[1-9][0-9]*$/; + die "No valid value for overwrite" if $data->{overwrite} && $data->{overwrite} !~ /^(0|1)$/; + die "No valid value for dry_run" if $data->{dry_run} && $data->{dry_run} !~ /^(0|1)$/; + + $self->{dry_run} = $data->{dry_run} ? 1 : 0; + $self->{overwrite} = $data->{overwrite} ? 1 : 0; + $self->{months} = $data->{months} ? $data->{months} : 6; + + my (@updates_vendor, @updates_customer); + + foreach my $vc_type (qw(customer vendor)) { + my $bank_vc = _get_bank_data_vc(vc => $vc_type, months => $self->{months}); + + foreach my $bank_vc_entry (@{ $bank_vc }) { + if ($bank_vc_entry->{remote_account_number}) { + my $vc = $vc_type eq 'customer' + ? SL::DB::Customer->new(id => $bank_vc_entry->{customer_id})->load + : SL::DB::Vendor ->new(id => $bank_vc_entry->{vendor_id}) ->load; + + next if $vc->can('mandate_date_of_signature') && $vc->mandate_date_of_signature; + next if $vc->iban && !$self->{overwrite}; + + push @updates_customer, $vc->name . " -> " . $bank_vc_entry->{remote_account_number} if $vc_type eq 'customer'; + push @updates_vendor, $vc->name . " -> " . $bank_vc_entry->{remote_account_number} if $vc_type eq 'vendor'; + + next if $self->{dry_run}; + + $vc->update_attributes(iban => $bank_vc_entry->{remote_account_number}, bic => $bank_vc_entry->{remote_bank_code}); + } + } + } + my $msg = $self->{dry_run} ? "DRY RUN Updates: " : "Updates: "; + $msg .= "Customer: " . join (',', @updates_customer) . "\n Vendors: " . join (',', @updates_vendor); + + return $msg; +} + +sub _get_bank_data_vc { + my (%params) = @_; + + die "Need a defined value for params(vc)" unless $params{vc}; + die "Need a defined value for params(months)" unless $params{months}; + + die "Need valid vc param, got:" . $params{vc} unless $params{vc} =~ /^(customer|vendor)$/; + die "Need valid months param, got:" . $params{months} unless $params{months} =~ /^[1-9][0-9]*$/; + + my $vc_id = $params{vc} . '_id'; + + my $arap = $params{vc} eq 'customer' ? 'ar' + : $params{vc} eq 'vendor' ? 'ap' + : undef; + + + my $dbh = SL::DB->client->dbh; + my $query = < now() - interval '$params{months} month' AND paid = amount) + AND bta.${arap}_id IS NOT NULL + GROUP BY bt.remote_account_number,bt.remote_bank_code, $vc_id + ORDER BY $vc_id +SQL + + my $result = selectall_hashref_query($::form, $dbh, $query); + + return $result; +} + +1; + +__END__ + +=encoding utf8 + +=head1 NAME + +SL::BackgroundJob::SetBankAccountsMasterData — +Background job for setting IBAN and BIC for Customers and Vendors +regarding to the booked bank transactions for this companies. + +=head1 SYNOPSIS + +This background job searches all invoices which are paid by bank transactions +and gets the IBAN and BIC for those transactions. +If the IBAN and BICs in the master data are not yet set, they will be +set via this background jobs. + +By default the job only adds IBAN and BIC for entries which have no +manual entry before. +The job accepts three parameters: + +C -> No data will be changed, instead the changes will be +written to the job journal. + +C -> The intervall in months for which invoices are fetched, defaults +to 6 (months). + +C -> If set to 1 values in the master data will be changed +even if they are already exists, except if a mandate_date_of_signature is +found. Those data sets won't be changed because kivitendo assumes that there +is a direct debit contract for exactly this account with this specific company. + +The job is deactivated by default. Administrators of installations +where such a feature is wanted have to create a job entry manually. + +=head1 AUTHOR + +Jan Büren Ejan@kivitendo.deE + +=cut diff --git a/SL/BackgroundJob/SetClosedTo.pm b/SL/BackgroundJob/SetClosedTo.pm new file mode 100644 index 000000000..ca7d133ce --- /dev/null +++ b/SL/BackgroundJob/SetClosedTo.pm @@ -0,0 +1,50 @@ +package SL::BackgroundJob::SetClosedTo; + +use strict; + +use parent qw(SL::BackgroundJob::Base); + +use SL::Helper::DateTime; + +sub create_job { + $_[0]->create_standard_job('1 0 10 * *'); # always the 10th of the month +} + + +sub run { + my ($self, $db_obj) = @_; + my $data = $db_obj->data_as_hash; + + my $subtract_month = $data->{subtract_month} || 1; + my $subtract_days = $data->{subtract_days} || 10; + + die "No integer number for days or month" unless ($subtract_month =~ m/^\d+\z/ + && $subtract_days =~ m/^\d+\z/); + + # new closedto + my $new_closedto = DateTime->now_local->subtract(months => $subtract_month, days => $subtract_days); + + my $defaults = SL::DB::Default->get; + + # dont accidently open the books + return 1 if ($defaults->closedto && $defaults->closedto >= $new_closedto); + + $defaults->closedto($new_closedto); + $defaults->save || die "Cannot save closedto!"; + + return 1; +} + +1; + +__END__ + +=encoding utf8 + +=head1 NAME + +SL::BackgroundJob::SetClosedTo - Background job for +periodically setting closedto (books closed until). +Defaults to the end of the second last month. + +=cut diff --git a/SL/BackgroundJob/SetNumberRange.pm b/SL/BackgroundJob/SetNumberRange.pm index a7976f472..ca25c9143 100644 --- a/SL/BackgroundJob/SetNumberRange.pm +++ b/SL/BackgroundJob/SetNumberRange.pm @@ -23,15 +23,40 @@ sub run { if ($data->{multiplier} && !($data->{multiplier} % 10 == 0)) { die "No valid input for multiplier should be 10, 100, .., 1000000"; } - my $next_year = DateTime->today_local->truncate(to => 'year')->add(years => 1)->year(); - $next_year = ($data->{digits_year} == 2) ? substr($next_year, 2, 2) : $next_year; + if ($data->{monthly} && $data->{monthly_strftime}) { + DateTime->today_local->strftime($data->{monthly_strftime}) // die "No valid input for montly_strftime"; + } + + # new year + my $running_year = $data->{current_year} ? DateTime->today_local->truncate(to => 'year') + : DateTime->today_local->truncate(to => 'year')->add(years => 1)->year(); + + $running_year = ($data->{digits_year} == 2) ? substr($running_year, 2, 2) : $running_year; + my $multiplier = $data->{multiplier} || 100; + # or new month + my $today_dt = DateTime->today_local; + + my $today = $data->{monthly_strftime} ? $today_dt->strftime($data->{monthly_strftime}) + : $today_dt->strftime('%y-%m-'); + + $today = $data->{monthly_postfix} ? $today . $data->{monthly_postfix} : $today . '000'; + my $defaults = SL::DB::Default->get; - foreach (qw(invnumber cnnumber sonumber ponumber sqnumber rfqnumber sdonumber pdonumber)) { - my $current_number = SL::PrefixedNumber->new(number => $defaults->{$_}); - $current_number->set_to($next_year * $multiplier); + my $current_number; + foreach (qw(invnumber cnnumber soinumber pqinumber sonumber ponumber pocnumber + sqnumber rfqnumber sdonumber pdonumber sudonumber rdonumber + s_reclamation_record_number p_reclamation_record_number )) { + + + if ($data->{monthly}) { + $current_number = SL::PrefixedNumber->new(number => $today); + } else { + $current_number = SL::PrefixedNumber->new(number => $defaults->{$_}); + $current_number->set_to($running_year * $multiplier); + } $defaults->{$_} = $current_number->get_current; } $defaults->save() || die "Could not change number ranges"; @@ -40,3 +65,47 @@ sub run { } 1; + +__END__ + +=encoding utf8 + +=head1 NAME + +SL::BackgroundJob::SetNumberRange — +Background job for setting all kivitendo number ranges for a new year or a new month + +=head1 SYNOPSIS + +The job can either be run annually or monthly and defaults to annually. + +The background job accepts the following optional json encoded parameters in the data for monthly mode: + +C: If set to true the job assumes it is the first of a new month + +C: A user postfix can be defined as a string for the new number. +If nothing is set three zeros are added as a postfix string ('000'). + +C: Year, month and day can optional be defined as user input in the +same way as the C strftime method. If nothing is set 'y%-m%' is the default. More options at the +time of writing can be found here: https://metacpan.org/pod/DateTime#strftime-Patterns + +The backgroud accepts the following optional json encoded parameters in the data for the annually mode (default): + +C: Multiplier to set the number range (defaults to 100) + +C: Handles the encoding of the year (can be 2 or 4, ie 24 or 2024). Defaults to 4 + +C: If set to 1 the current year will be used and not the next year. + + +The latter option is useful if the jobs runs on the 1st of the new year. + +The job is deactivated by default. Administrators of installations +where such a feature is wanted have to create a job entry manually. + +=head1 AUTHOR + +Jan Büren Ejan@kivitendo.deE + +=cut diff --git a/SL/BackgroundJob/ShopOrderMassTransfer.pm b/SL/BackgroundJob/ShopOrderMassTransfer.pm index 62437a706..0bfa1304d 100644 --- a/SL/BackgroundJob/ShopOrderMassTransfer.pm +++ b/SL/BackgroundJob/ShopOrderMassTransfer.pm @@ -61,6 +61,7 @@ sub create_order { }else{ $order->save; $order->calculate_prices_and_taxes; + SL::DB::OrderVersion->new(oe_id => $order->id, version => 1)->save; my $snumbers = "ordernumber_" . $order->ordnumber; SL::DB::History->new( trans_id => $order->id, diff --git a/SL/BackgroundJob/ShopPartMassCreate.pm b/SL/BackgroundJob/ShopPartMassCreate.pm new file mode 100644 index 000000000..068d0b5f4 --- /dev/null +++ b/SL/BackgroundJob/ShopPartMassCreate.pm @@ -0,0 +1,249 @@ +package SL::BackgroundJob::ShopPartMassCreate; + +use strict; + +use parent qw(SL::BackgroundJob::Base); + +use List::Util qw(first); +use File::MimeInfo::Magic; # for mimetype +use File::Slurp; # for read_file + +use SL::Shop; +use SL::DB::Part; +use SL::DB::ShopPart; +use SL::DB::File; +use SL::CVar; +use SL::File; +use SL::LXDebug; + +sub recurse_categories { + my ($categories, $categories_by_names) = @_; + foreach my $category (@{ $categories }) { + ${ $categories_by_names }{$category->{name}} = $category->{id}; + recurse_categories($category->{children}, $categories_by_names); + } +} + +sub get_shop_part { + my ($part_id, $shop_id) = @_; + my $exists = SL::DB::Manager::ShopPart->find_by( part_id => $part_id, shop_id => $shop_id ); + if ($exists) { + return $exists; + } + return SL::DB::ShopPart->new( part_id => $part_id, shop_id => $shop_id ); +} + +sub get_shop_categories { + my ($cvar_categories, $categories_by_names) = @_; + # split on the pipe character + my @categories_names = split(/\|/, $cvar_categories); + + my @shop_categories; + foreach my $category_name (@categories_names) { + # if the category exists in the shop + if (exists $categories_by_names->{$category_name}) { + push @shop_categories, [ + $categories_by_names->{$category_name}, + $category_name + ]; + } + } + return \@shop_categories; +} + +sub sanitize_filename { + my ($filename) = @_; + $filename =~ s/\W/_/g; + return $filename; +} + +sub _warn { + my ($messages, $message) = @_; + $main::lxdebug->message(LXDebug::WARN(), $message); + push @$messages, $message; +} + +sub run { + my ($self, $db_obj) = @_; + my $data = $db_obj->data_as_hash; + + # get parameters + my $shop_id = $data->{shop_id} || 1; + my $images_import_path = $data->{images_import_path} || 'shopimages/product/'; + my $cvar_categories = $data->{cvar_categories} || 'vm_product_categories'; + + my @messages; + + # initialize shop + my $shop_config = SL::DB::Manager::Shop->get_first( query => [ id => $shop_id ] ); + my $shop = SL::Shop->new( config => $shop_config ); + + # get the categories from the shop + my $connect = $shop->check_connectivity; + if (!$connect->{success}) { + return 'Error: could not connect to shop'; + } + my $categories_shopdata = $shop->connector->get_categories(); + if (!$categories_shopdata) { + return 'Error: could not get categories from shop'; + } + + # generate a hash of the category names and their ids + my %categories_by_names; + recurse_categories($categories_shopdata, \%categories_by_names); + + # get all the parts from the database, that are marked as shop parts + my $parts = SL::DB::Manager::Part->get_all(query => [ shop => 1 ]); + + # for every part + for my $part (@{ $parts }) { + + # check if shop part already exists + my $shop_part = get_shop_part($part->id, $shop_id); + + # get the custom variables from the part + my $cvars = CVar->get_custom_variables(module => 'IC', trans_id => $part->id); + my $cvar_categories = first { $_->{name} eq $cvar_categories } @{ $cvars }; + + # assign categories + my $shop_categories = get_shop_categories($cvar_categories->{value}, \%categories_by_names); + + $shop_part->assign_attributes( + shop_description => '', + front_page => '', + active => 1, + shop_category => $shop_categories, + active_price_source => 'master_data/sellprice', + metatag_keywords => '', + metatag_description => '', + metatag_title => '', + ); + + $shop_part->save; + $main::lxdebug->message(LXDebug->DEBUG1(), 'Shop part saved: ' . $shop_part->id); + + if (!$shop_part->id) { + _warn(\@messages, 'Warning: shop part not saved, part id: ' . $part->id . ' part number: ' . $part->partnumber); + next; + } + + # handle the images, + # the file names are under part->image + + if (!$part->image) { + # go to next part if no images are found + next; + } + + # get existing images from shop part + my $image_files = SL::DB::Manager::File->get_all( where => [ object_id => $part->id, object_type => 'shop_image' ] ); + + my %images_by_names = map { $_->{file_name} => $_ } @{ $image_files }; + + for my $image_name (split '\|', $part->image) { + + my $fileobj; + if (exists $images_by_names{$image_name}) { + # I tried updating or deleting the file, but that didn't work + # so for now we'll just skip the image if an image with the same name already exists + # (atm there doesn't seem to be a mechanism in place to update or delete the files properly) + next; + } + + my $image_path = $images_import_path . $image_name; + + # uses File::MimeInfo::Magic + my $mime_type = mimetype($image_path); + + # check if the file exists + if (! -e $image_path) { + _warn(\@messages, 'Warning: image file not found for part: ' . $part->id . ' file: ' . $image_name); + next; + } + # read file data into memory + my $file_data = File::Slurp::read_file($image_path); + + $fileobj = SL::File->save( + object_id => $part->id, + object_type => 'shop_image', + mime_type => $mime_type, + source => 'uploaded', + file_type => 'image', + file_name => $image_name, + title => sanitize_filename(substr($part->description, 0, 45)), + description => '', + file_contents => $file_data, + file_path => $image_path, + ); + if (!$fileobj) { + _warn(\@messages, 'Warning: file not saved for part: ' . $part->id . ' file: ' . $image_name); + } + } + } + + if (@messages) { + return join("\n", @messages); + } + return 'Shop parts created successfully'; +} + +1; + +__END__ + +=encoding utf8 + +=head1 NAME + +SL::BackgroundJob::ShopPartMassCreate - Background job to create shop +parts for all parts in the database that are marked as shop parts. + +=head1 SYNOPSIS + +This background job provides the basic functionality to create shop parts for +all parts in the database that are marked as shop parts. + +It can also import images from a directory and assign them to the respective +shop parts. See configuration below. + +It also assigns categories to the shop parts based on a custom variable of the +part. + +The script may need individual adjustments to fit your specific use case. + +=head1 CONFIGURATION + +Accepts the following parameters: + +=over 4 + +=item C + +The id of the shop to create the shop parts in, defaults to 1 + +=item C + +The path to the images to import, defaults to 'shopimages/product/' + +The file names of the images should be present in the 'image' field of the part, +in the following format: + image1.jpg|image2.png|image3.gif + +The images themselves should be present in the images_import_path. + +=item C + +The name of the custom variable that contains the categories, defaults to 'vm_product_categories' + +Expects the Categories to be set in the custom variable of the part in the following format: + Category1|Category2|Category3 + +Categories should be present in the shop under the same names. + +=back + +=head1 AUTHOR + +Cem Aydin Ecem.aydin@revamp-it.chE + +=cut diff --git a/SL/BackgroundJob/ShopwareSetPaid.pm b/SL/BackgroundJob/ShopwareSetPaid.pm new file mode 100644 index 000000000..738701316 --- /dev/null +++ b/SL/BackgroundJob/ShopwareSetPaid.pm @@ -0,0 +1,72 @@ +package SL::BackgroundJob::ShopwareSetPaid; + +use strict; + +use parent qw(SL::BackgroundJob::Base); + +use SL::DB::Invoice; +use SL::Locale::String qw(t8); +use SL::Shop; + +sub run { + my ($self, $db_obj) = @_; + my $data = $db_obj->data_as_hash; + + my $dry_run = ($data->{dry_run}) ? 1 : 0; + my $today = ($data->{datepaid}) ? DateTime->from_kivitendo($data->{datepaid}) : DateTime->today_local; + + my $paid_invoices = SL::DB::Manager::Invoice->get_all(query => [ and => [ datepaid => { ge => $today }, amount => \'paid' ]]); + + my @shoporders; + foreach my $invoice (@{ $paid_invoices }) { + # check if we have a shop order invoice + my @linked_shop_orders = $invoice->linked_records( + from => 'ShopOrder', + via => ['DeliveryOrder','Order'], + ); + my $shop_order = $linked_shop_orders[0][0]; + if ( $shop_order ) { + #do update + push @shoporders, $shop_order->shop_ordernumber; + next if $dry_run; + my $shop_config = SL::DB::Manager::Shop->get_first( query => [ id => $shop_order->shop_id ] ); + my $shop = SL::Shop->new( config => $shop_config ); + $shop->connector->set_order_transaction_status($shop_order->shop_ordernumber, "paid"); + } + } + # nothing found + return t8("No valid invoice(s) found") if scalar @shoporders == 0; + + my $message = t8("The following Shop Orders: ") . join (', ', @shoporders); + $message .= $dry_run ? t8(" would be set to the state 'paid'") : t8(" have been set to the state 'paid'"); + + return $message; +} + +1; + +__END__ + +=encoding utf8 + +=head1 NAME + +SL::BackgroundJob::ShopwareSetPaid + +Background job for setting the shopware state paid for shopware orders + +With the default values the job should be run once a day after all payments are booked. + +=head1 SYNOPSIS + +Accepts two params 'dry_run' and 'datepaid'. +If 'dry_run' has trueish vale, the job simply returns what would have been done in the Background Job Journal. +If 'datepaid' is set all Invoices with a datepaid higher or equal the 'datepaid' value are checked. Date should be +in the correct system locales. If ommitted datepaid will be the current date. + + +=head1 AUTHOR + +Jan Büren + +=cut diff --git a/SL/BackgroundJob/SyncEmailFolder.pm b/SL/BackgroundJob/SyncEmailFolder.pm new file mode 100644 index 000000000..e88289ae3 --- /dev/null +++ b/SL/BackgroundJob/SyncEmailFolder.pm @@ -0,0 +1,98 @@ +package SL::BackgroundJob::SyncEmailFolder; + +use strict; +use warnings; + +use parent qw(SL::BackgroundJob::Base); + +use Params::Validate qw(:all); + +use SL::IMAPClient; +use SL::DB::Manager::EmailImport; + +sub sync_email_folder { + my ($self) = @_; + my $folder = $self->{job_obj}->data_as_hash->{folder}; + + my $imap_client = SL::IMAPClient->new(%{$::lx_office_conf{imap_client}}); + + my $email_import = $imap_client->update_emails_from_folder( + folder => $folder + ); + return unless $email_import; + + return "Created email import: " . $email_import->id; +} + +sub delete_email_imports { + my ($self) = @_; + my $job_obj = $self->{job_obj}; + + my $email_import_ids_to_delete = + $job_obj->data_as_hash->{email_import_ids_to_delete} || []; + + my @deleted_email_imports_ids; + foreach my $email_import_id (@$email_import_ids_to_delete) { + my $email_import = SL::DB::Manager::EmailImport->find_by(id => $email_import_id); + next unless $email_import; + $email_import->delete(cascade => 1); + push @deleted_email_imports_ids, $email_import_id; + } + return unless @deleted_email_imports_ids; + + return "Deleted email import(s): " . join(', ', @deleted_email_imports_ids); +} + +sub run { + my ($self, $job_obj) = @_; + $self->{job_obj} = $job_obj; + my @bj_data = $job_obj->data_as_hash; + validate_with( + params => \@bj_data, + spec => { + folder => { + type => + SCALAR, optional => 1 + }, + email_import_ids_to_delete => { + type => ARRAYREF, + optional => 1, + } + }, + called => "data filed in Background Job", + ); + + my @results; + push @results, $self->delete_email_imports(); + push @results, $self->sync_email_folder(); + + return join(". ", grep { $_ ne ''} @results); +} + +1; + +__END__ + +=encoding utf8 + +=head1 NAME + +SL::BackgroundJob::SyncEmailFolder - Background job for syncing emails from a folder + +=head1 SYNOPSIS + +This background job is used to sync emails from a folder. It can be used to sync +emails from a folder on a regular basis for multiple folders. The folder to sync +is specified in the data field 'folder' of the background job, by default the +folder 'base_folder' from IMAP client is used. Sub folders are separated by a +forward slash, e.g. 'INBOX/Archive'. Subfolders are not synced. + +=head1 BUGS + +Nothing here yet. + +=head1 AUTHOR + +Tamino Steinert Etamino.steinert@tamino.stE + +=cut diff --git a/SL/BackgroundJob/SyncWebDAV.pm b/SL/BackgroundJob/SyncWebDAV.pm new file mode 100644 index 000000000..9b325ae73 --- /dev/null +++ b/SL/BackgroundJob/SyncWebDAV.pm @@ -0,0 +1,163 @@ +package SL::BackgroundJob::SyncWebDAV; + +use strict; +use warnings; + +use parent qw(SL::BackgroundJob::Base); + +use SL::DB::BackgroundJobHistory; +use HTTP::DAV; +use File::Find; +use Cwd; +use Data::Dumper; + +sub create_job { + $_[0]->create_standard_job('0 3 * * *'); # daily at 3:00 am +} + +sub run { + my $self = shift; + my $db_obj = shift; + + my $options = $db_obj->data_as_hash; + my $DELETE_ONLY = 0 || $options->{delete}; + + return unless $::instance_conf->get_webdav_sync_extern; + + my $ret; + + my $dav = HTTP::DAV->new(); + my $url = $::instance_conf->get_webdav_sync_extern_url; + $url =~ s|/\z||; # no trailing slashes + + $dav->credentials( + -user => $::instance_conf->get_webdav_sync_extern_login, + -pass => $::instance_conf->get_webdav_sync_extern_pass, + -url => $url, + ); + my $client_id = $options->{client_id} || $::auth->get_session_value('client_id'); + my $cwd = getcwd(); + + my @fails; + + eval { + + my (@webdav_dir_temp, @webdav_dir, @webdav_files); + + # chdir to client root + my $webdav = $cwd. "/webdav/$client_id/"; + chdir($webdav) or die "couldn't change into webdav dir"; # TODO throw better error message (Permission denied, etc) + + find( { wanted => sub { push @webdav_dir_temp, -d && $_}, no_chdir => 1 }, '.'); + find( { wanted => sub { push @webdav_files, -f && $_}, no_chdir => 1 }, '.'); + + shift @webdav_dir_temp; # first element would be undef after substr + foreach (@webdav_dir_temp) { + next unless $_; + push @webdav_dir, substr($_,2); + } + @webdav_files = map { substr($_,2) } grep { $_ =~ m/.*pdf/ } @webdav_files; + + $ret = $dav->open(-url => $url) or die "Can't open url $url"; + # Make a null lock on repo for 5minutes + #$ret = $dav->lock(-url => $url, -timeout => "30m") or die; + + foreach (@webdav_dir) { + last if $DELETE_ONLY; + + $ret = $dav->options(-url => $url . '/' . $_); + next unless $ret =~ m/MKCOL/; + + unless ( $dav->mkcol($_) ) { + push(@fails, "Cannot make dir $_"); + }; + } + + #$dav->unlock(-url => $url); # UNLOCK after DIR sync + # now we have all dirs in sync, therefore we can place files + foreach (@webdav_files) { + last if $DELETE_ONLY; + + $ret = $dav->options(-url => $url . '/' . $_); + # $main::lxdebug->message(0, 'verzeichnis:'. $_ . '::' . $ret . ':' . $dav->message); + next unless $ret =~ m/MKCOL/; # file not there #owncloud gives DELETE even if file not there + #$dav->lock(-url => $url . '/' . $_); # UNLOCK after DIR sync + + # $main::lxdebug->message(0, 'datei:'. $_); + unless ( $dav->put(-local => $_, -url => $url . '/' . $_) ) { + push(@fails, "Cannot put file $_"); + }; + #$dav->unlock(-url => $url . '/' . $_); # UNLOCK after put + } + + # maybe we delete some stuff + # TODO delete stuff here + if ($DELETE_ONLY) { + foreach (qw(anfragen bestellungen einkaufslieferscheine einkaufsrechnungen angebote + gutschriften lieferantenbestellungen rechnungen verkaufslieferscheine)) { + $ret = $dav->delete($url . "/$_"); + } + + # better, but not implemented - delete only local deleted stuff + # idea: propfind all the above dirs and check if child (rel_uri) exists locally + # if not, we can safely delete remote + # if (my $r=$dav->propfind( -url=>"$_/", -depth=>1) ) { ... + } + + #$dav->unlock(-url => $url); + chdir($cwd); + + 1; + + } or do { + my $error = "dav: " . $dav->message . ", eval: " . $! . ", eval 2: " . $@; + # $dav->unlock(-url => $url); # unlock, just in case + # chdir($cwd); + die("Couldn't sync with external webdav repo at $url error code/protocol return:" . $error); + }; + + if ( @fails ) { + die join("\n", @fails); + }; + + return 1; +} + +1; + +__END__ + +=encoding utf8 + +=head1 NAME + +SL::BackgroundJob::ExternalSyncWebDAV - Background job for +syncing all folders and files for current client to a external +webdav-repository + +=head1 SYNOPSIS + +This background job copies all files and folders for one client +to a external webdav-repo. +A optional param C can be set to 1 to delete (clean) +the external repo. If set to undef or 0 a folderwise copy will be +executed. +To test with different clients a param C will overload +the current client id. +The settings for the external repo are in client config. +If a lock still exists, the job returns a Internal Server Error +from the webdav server. +Only pdf files are considered valid files to copy. + +The job is supposed to run once a day. + +=head1 BUGS + +Nothing here yet. + +=head1 AUTHOR + +Jan Büren Ejan@kivitendo-premium.deE + +=cut + diff --git a/SL/BackgroundJob/UpdateEmployeeBaseData.pm b/SL/BackgroundJob/UpdateEmployeeBaseData.pm new file mode 100644 index 000000000..b61a6b487 --- /dev/null +++ b/SL/BackgroundJob/UpdateEmployeeBaseData.pm @@ -0,0 +1,44 @@ +package SL::BackgroundJob::UpdateEmployeeBaseData; + +use strict; +use utf8; + +use parent qw(SL::BackgroundJob::Base); + +use SL::DB::BackgroundJob; +use SL::DB::Employee; + +sub run { + my ($self, $db_obj, $end_date) = @_; + + SL::DB::Manager::Employee->update_entries_for_authorized_users; + + return 1; +} + +1; +__END__ + +=pod + +=encoding utf8 + +=head1 NAME + +SL::BackgroundJob::UpdateEmployeeBaseData - Background job for copying +user data from the auth database to the "employee" table + +=head1 OVERVIEW + +When authentication via HTTP headers is active the regular login +routine is skipped. That routine would normally copy values from the +auth database to the employee table. This job can be run regularly to +copy the same values. + +The job is enabled & set to run every five minutes by default. + +=head1 AUTHOR + +Moritz Bunkus Em.bunkus@linet.deE + +=cut diff --git a/SL/BackgroundJob/UpdateExchangerates.pm b/SL/BackgroundJob/UpdateExchangerates.pm new file mode 100644 index 000000000..3573257f9 --- /dev/null +++ b/SL/BackgroundJob/UpdateExchangerates.pm @@ -0,0 +1,164 @@ +package SL::BackgroundJob::UpdateExchangerates; + +use strict; +use utf8; + +use parent qw(SL::BackgroundJob::Base); + +use SL::DB::Exchangerate; +use SL::DB::Currency; + +use Rose::Object::MakeMethods::Generic ( + scalar => [ qw(worker) ], +); + + +sub create_job { + my $self_or_class = shift; + + my $package = ref($self_or_class) || $self_or_class; + $package =~ s/SL::BackgroundJob:://; + + my $cron_spec = ('35 4 * * *'); # every day at 4:35 am + + my $data = < $cron_spec, + type => 'interval', + active => 1, + package_name => $package, + data => $data); + + my $job = SL::DB::Manager::BackgroundJob->find_by(package_name => $params{package_name}); + if (!$job) { + $job = SL::DB::BackgroundJob->new(%params)->update_next_run_at; + } else { + $job->assign_attributes(%params)->update_next_run_at; + } + + return $job; +} + +sub run { + my ($self, $db_obj) = @_; + + my $params = $db_obj->data_as_hash; + + return $::locale->text('Parameter module must be given.') if !$params->{module}; + + # instanciate worker for given module + my $error; + eval { + my $worker_class = 'SL::BackgroundJob::UpdateExchangerates::' . $params->{module}; + eval "require $worker_class"; + $self->worker($worker_class->new(options => $params->{options})); + 1; + } or do { + $error = $::locale->text('Could not load class #1 (#2): "#3"', $params->{module}, 'SL/BackgroundJob/UpdateExchangerates', $@); + }; + return $error if $error; + + my $default_currency = SL::DB::Currency->new(id => $::instance_conf->get_currency_id)->load; + my $transdate = DateTime->today_local; + my @rates_to_update; + + # collect currencies that should be updated + foreach my $currency (@{SL::DB::Manager::Currency->get_all_sorted}) { + next if $currency->id == $default_currency->id; + + my $exrate = SL::DB::Manager::Exchangerate->find_by(transdate => $transdate, currency_id => $currency->id); + + if (!$exrate) { + push @rates_to_update, {from => $default_currency, + to => $currency, + dir => 'buy'}; + push @rates_to_update, {from => $default_currency, + to => $currency, + dir => 'sell'}; + next; + } + + if (!$exrate->buy) { + push @rates_to_update, {from => $default_currency, + to => $currency, + dir => 'buy'}; + } + if (!$exrate->sell) { + push @rates_to_update, {from => $default_currency, + to => $currency, + dir => 'sell'}; + } + } + + return "updated: 0" if scalar @rates_to_update == 0; + + # update rates + $self->worker->update_rates(\@rates_to_update); + + # save rates + my @updated; + foreach my $rate (@rates_to_update) { + my $exrate = SL::DB::Manager::Exchangerate->find_by_or_create(transdate => $transdate, currency_id => $rate->{to}->id); + + next if !$exrate; # should not happen + + if ($rate->{rate}) { + $exrate->transdate($transdate) if !$exrate->transdate; + $exrate->currency($rate->{to}) if !$exrate->currency; + + my $method = $rate->{dir}; + if (!$exrate->$method) { + $exrate->$method($rate->{rate}); + $exrate->save; + push @updated, $rate->{to}->name . " ($method: " . $rate->{rate} . ")"; + } + } + } + + return "updated: " . scalar @updated . ': ' . join ', ', @updated; +} + + +1; + + +__END__ + +=encoding utf8 + +=head1 NAME + +SL::BackgroundJob::UpdateExchangerates - Background job for updating the +exchange rates for currencies + +=head1 SYNOPSIS + +This background job can update all exchange rates for currencies if the rates +are not already present for the current date. +A worker module must be given as data to the job (see documentation at +SL::BackgroundJob::UpdateExchangerates::Base and +SL::BackgroundJob::UpdateExchangerates::* as examples). +The worker will be used to get the actual rates from some kind of service. +Options to the worker can be given as data to the background job: + +module: FromOpenexchangerates +options: + api_id: 1234565789 + translate: + £: GBP + +=head1 Todo + +Better error handling / error notification + +=head1 AUTHOR + +Bernd Bleßmann Ebernd@kivitendo-premium.deE + +=cut + diff --git a/SL/BackgroundJob/UpdateExchangerates/Base.pm b/SL/BackgroundJob/UpdateExchangerates/Base.pm new file mode 100644 index 000000000..84198d73a --- /dev/null +++ b/SL/BackgroundJob/UpdateExchangerates/Base.pm @@ -0,0 +1,111 @@ +package SL::BackgroundJob::UpdateExchangerates::Base; + +use strict; + +use parent qw(Rose::Object); + +use Rose::Object::MakeMethods::Generic ( + scalar => [ qw(options) ], +); + +sub update_rates { + my ($self, $rates) = @_; + die 'needs to be overwritten'; +} + +sub translate_currency_name { + my ($self, $name) = @_; + + return $name if (!$self->options->{translate}); + return $self->options->{translate}->{$name} if $self->options->{translate}->{$name}; + return $name; +} + + +1; + + +__END__ + +=encoding utf-8 + +=head1 NAME + +SL::BackgroundJob::UpdateExchangerates::Base - Base class for background job to update exchange rates. + +=head1 SYNOPSIS + + # in update-worker: + use parent qw(SL::BackgroundJob::UpdateExchangerates::Base); + + # implement interface + sub update_rates { + my ($self, $rates) = @_; + + foreach my $rate (@$rates) { + my $from = $self->translate_currency_name($rate->{from}->name); + my $to = $self->translate_currency_name($rate->{to}->name); + if ( $from eq 'EUR' && $to eq 'USD') { + $rate->{rate} = 0.9205 if $rate->{dir} eq 'buy'; + $rate->{rate} = 0.9202 if $rate->{dir} eq 'sell'; + } + } + } + +=head1 DESCRIPTION + +This is a base class for a worker to update exchange rates. + +=head1 INTERFACE + +=over 4 + +=item C + +Your class will be instanciated and the update_rates method will be invoked. +This method can update known requeseted rates. Therefor an array of hashes with +information of the requested rates is provided. Each hash consists of the +following keys: + +=over 5 + +=item + +from: currency (instance of SL::DB::Currency) to be converted from + +=item + +to: currency (instance of SL::DB::Currency) to be converted to + +=item + +dir: 'bye' or 'sell' + +=back + +Your class should add a 'rate'-entry to each hash, if it can provide the rate +information. If not, it should leave the hash-entry as it is. + +=back + +=head1 FUNCTIONS + +=over 4 + +=item C + +Returns the translated currency name, if a translation is given. This can be used to translate client specific +currency notations to the one used by the worker module. Translations are give as data to the background job: + +options: + translate: + £: GBP + +=back + +=head1 AUTHOR + +Bernd Bleßmann Ebernd@kivitendo-premium.deE + +=cut + diff --git a/SL/BackgroundJob/UpdateExchangerates/FromOpenexchangerates.pm b/SL/BackgroundJob/UpdateExchangerates/FromOpenexchangerates.pm new file mode 100644 index 000000000..fb510cded --- /dev/null +++ b/SL/BackgroundJob/UpdateExchangerates/FromOpenexchangerates.pm @@ -0,0 +1,47 @@ +package SL::BackgroundJob::UpdateExchangerates::FromOpenexchangerates; + +use strict; +use utf8; + +use parent qw(SL::BackgroundJob::UpdateExchangerates::Base); + +use LWP::Simple; +use SL::JSON; + + +sub update_rates { + my ($self, $rates) = @_; + + return if !$self->options->{api_id}; + + # 'https://openexchangerates.org/api/latest.json?app_id=xxx&base=EUR'; + # setting base does not work for me, so get for default base USD and calculate ... + my $url; + $url .= 'https://openexchangerates.org/api/latest.json?app_id='; + $url .= $self->options->{api_id}; + + my $result = get($url); + return if !$result; + my $result_h = decode_json($result); + + foreach my $rate (@$rates) { + my $base_rate = $result_h->{rates}->{ $self->translate_currency_name($rate->{from}->name) }; + next if !$base_rate; + + my $target_rate = $result_h->{rates}->{ $self->translate_currency_name($rate->{to}->name) }; + next if !$target_rate; + + my $exrate = $base_rate/$target_rate; + + # buy and sell are the same, so do not differenciate + $rate->{rate} = $exrate; + } +} + + +1; + + +#module: FromOpenexchangerates +#options: +# api_id: ce3e48c3f3a54c4d968530a08bb87734 diff --git a/SL/BackgroundJob/UpdateExchangerates/SimpleTest.pm b/SL/BackgroundJob/UpdateExchangerates/SimpleTest.pm new file mode 100644 index 000000000..4fd3173bc --- /dev/null +++ b/SL/BackgroundJob/UpdateExchangerates/SimpleTest.pm @@ -0,0 +1,22 @@ +package SL::BackgroundJob::UpdateExchangerates::SimpleTest; + +use strict; +use utf8; + +use parent qw(SL::BackgroundJob::UpdateExchangerates::Base); + + +sub update_rates { + my ($self, $rates) = @_; + + foreach my $rate (@$rates) { + my $from = $self->translate_currency_name($rate->{from}->name); + my $to = $self->translate_currency_name($rate->{to}->name); + if ( $from eq 'EUR' && $to eq 'USD') { + $rate->{rate} = 0.9205 if $rate->{dir} eq 'buy'; + $rate->{rate} = 0.9202 if $rate->{dir} eq 'sell'; + } + } +} + +1; diff --git a/SL/BackgroundJob/ValidityTokenCleanup.pm b/SL/BackgroundJob/ValidityTokenCleanup.pm new file mode 100644 index 000000000..a03f04d05 --- /dev/null +++ b/SL/BackgroundJob/ValidityTokenCleanup.pm @@ -0,0 +1,30 @@ +package SL::BackgroundJob::ValidityTokenCleanup; + +use strict; + +use parent qw(SL::BackgroundJob::Base); + +use SL::DB::ValidityToken; + +sub create_job { + $_[0]->create_standard_job('0 3 * * *'); # daily +} + +sub run { + SL::DB::Manager::ValidityToken->cleanup; + + return 1; +} + +1; + +__END__ + +=encoding utf8 + +=head1 NAME + +SL::BackgroundJob::ValidityTokenCleanup - Background job for +deleting all expired validity tokens + +=cut diff --git a/SL/CP.pm b/SL/CP.pm index 75e753e07..278133f53 100644 --- a/SL/CP.pm +++ b/SL/CP.pm @@ -136,7 +136,7 @@ sub get_openinvoices { # if this is a foreign currency transaction get exchangerate $ref->{exchangerate} = - $form->get_exchangerate($dbh, $ref->{curr}, $ref->{transdate}, $buysell) + $form->check_exchangerate($myconfig, $ref->{curr}, $ref->{transdate}, $buysell) if ($form->{currency} ne $form->{defaultcurrency}); push @{ $form->{PR} }, $ref; } diff --git a/SL/CT.pm b/SL/CT.pm index b52c8794a..f6d95484f 100644 --- a/SL/CT.pm +++ b/SL/CT.pm @@ -66,6 +66,8 @@ sub search { "customernumber" => "ct.customernumber", "vendornumber" => "ct.vendornumber", "name" => "ct.name", + "department_1" => "ct.department_1", + "department_2" => "ct.department_2", "contact" => "ct.contact", "phone" => "ct.phone", "fax" => "ct.fax", @@ -84,10 +86,12 @@ sub search { "insertdate" => "ct.itime", "salesman" => "e.name", "payment" => "pt.description", + "taxzone" => "tz.description", "pricegroup" => "pg.pricegroup", "ustid" => "ct.ustid", "creditlimit" => "ct.creditlimit", "commercial_court" => "ct.commercial_court", + "dunning_lock" => "ct.dunning_lock", ); $form->{sort} ||= "name"; @@ -102,7 +106,7 @@ sub search { } my $sortdir = !defined $form->{sortdir} ? 'ASC' : $form->{sortdir} ? 'ASC' : 'DESC'; - if ($sortorder !~ /(business|creditlimit|id|discount|itime)/ && !$join_records) { + if ($sortorder !~ /(business|creditlimit|id|discount|itime|dunning_lock)/ && !$join_records) { $sortorder = "lower($sortorder) ${sortdir}"; } else { $sortorder .= " ${sortdir}"; @@ -113,8 +117,8 @@ sub search { push(@values, like($form->{"${cv}number"})); } - foreach my $key (qw(name contact email)) { - if ($form->{$key}) { + foreach my $key (qw(name department_1 department_2 contact email)) { + if ($form->{$key} ne '') { $where .= " AND ct.$key ILIKE ?"; push(@values, like($form->{$key})); } @@ -221,6 +225,16 @@ sub search { push(@values, conv_i($form->{salesman_id})); } + if ($form->{payment_id}) { + $where .= qq| AND (ct.payment_id = ?)|; + push(@values, $form->{payment_id}); + } + + if ($form->{taxzone_id}) { + $where .= qq| AND (ct.taxzone_id = ?)|; + push(@values, $form->{taxzone_id}); + } + if($form->{insertdatefrom}) { $where .= qq| AND (ct.itime::DATE >= ?)|; push@values, conv_date($form->{insertdatefrom}); @@ -231,6 +245,11 @@ sub search { push @values, conv_date($form->{insertdateto}); } + if($form->{dunning_lock} ne '') { + $where .= qq| AND ct.dunning_lock = ?|; + push @values, $form->{dunning_lock}; + } + if ($form->{all}) { my @tokens = parse_line('\s+', 0, $form->{all}); $where .= qq| AND ( @@ -287,7 +306,7 @@ sub search { } my $query = qq|SELECT ct.*, ct.itime::DATE AS insertdate, b.description AS business, e.name as salesman, | . - qq| pt.description as payment | . + qq| pt.description as payment, tz.description as taxzone | . $pg_select . $main_cp_select . (qq|, NULL AS invnumber, NULL AS ordnumber, NULL AS quonumber, NULL AS invid, NULL AS module, NULL AS formtype, NULL AS closed | x!! $join_records) . @@ -295,6 +314,7 @@ sub search { qq|LEFT JOIN business b ON (ct.business_id = b.id) | . qq|LEFT JOIN employee e ON (ct.salesman_id = e.id) | . qq|LEFT JOIN payment_terms pt ON (ct.payment_id = pt.id) | . + qq|LEFT JOIN tax_zones tz ON (ct.taxzone_id = tz.id) | . $pg_join . qq|WHERE $where|; @@ -310,7 +330,7 @@ sub search { $query .= qq| UNION | . qq|SELECT ct.*, ct.itime::DATE AS insertdate, b.description AS business, e.name as salesman, | . - qq| pt.description as payment | . + qq| pt.description as payment, tz.description as taxzone | . $pg_select . $main_cp_select . qq|, a.invnumber, a.ordnumber, a.quonumber, a.id AS invid, | . @@ -321,6 +341,7 @@ sub search { qq|LEFT JOIN business b ON (ct.business_id = b.id) | . qq|LEFT JOIN employee e ON (ct.salesman_id = e.id) | . qq|LEFT JOIN payment_terms pt ON (ct.payment_id = pt.id) | . + qq|LEFT JOIN tax_zones tz ON (ct.taxzone_id = tz.id) | . $pg_join . qq|WHERE $where AND (a.invoice = '1')|; } @@ -330,7 +351,7 @@ sub search { $query .= qq| UNION | . qq|SELECT ct.*, ct.itime::DATE AS insertdate, b.description AS business, e.name as salesman, | . - qq| pt.description as payment | . + qq| pt.description as payment, tz.description as taxzone | . $pg_select . $main_cp_select . qq|, ' ' AS invnumber, o.ordnumber, o.quonumber, o.id AS invid, | . @@ -340,8 +361,9 @@ sub search { qq|LEFT JOIN business b ON (ct.business_id = b.id) | . qq|LEFT JOIN employee e ON (ct.salesman_id = e.id) | . qq|LEFT JOIN payment_terms pt ON (ct.payment_id = pt.id) | . + qq|LEFT JOIN tax_zones tz ON (ct.taxzone_id = tz.id) | . $pg_join . - qq|WHERE $where AND (o.quotation = '0')|; + qq|WHERE $where AND ((o.record_type = 'sales_order') OR (o.record_type = 'purchase_order'))|; } if ( $form->{l_quonumber} ) { @@ -349,7 +371,7 @@ sub search { $query .= qq| UNION | . qq|SELECT ct.*, ct.itime::DATE AS insertdate, b.description AS business, e.name as salesman, | . - qq| pt.description as payment | . + qq| pt.description as payment, tz.description as taxzone | . $pg_select . $main_cp_select . qq|, ' ' AS invnumber, o.ordnumber, o.quonumber, o.id AS invid, | . @@ -359,8 +381,9 @@ sub search { qq|LEFT JOIN business b ON (ct.business_id = b.id) | . qq|LEFT JOIN employee e ON (ct.salesman_id = e.id) | . qq|LEFT JOIN payment_terms pt ON (ct.payment_id = pt.id) | . + qq|LEFT JOIN tax_zones tz ON (ct.taxzone_id = tz.id) | . $pg_join . - qq|WHERE $where AND (o.quotation = '1')|; + qq|WHERE $where AND ((o.record_type = 'sales_quotation') OR (o.record_type = 'request_quotation'))|; } } diff --git a/SL/Common.pm b/SL/Common.pm index 126a30ed8..3b2df5bc9 100644 --- a/SL/Common.pm +++ b/SL/Common.pm @@ -340,7 +340,7 @@ sub get_vc_details { map { $form->{$_} = $form->format_amount($myconfig, $form->{$_} * 1) } qw(discount creditlimit); - $query = qq|SELECT * FROM shipto WHERE (trans_id = ?)|; + $query = qq|SELECT * FROM shipto WHERE (trans_id = ?) AND module LIKE 'CT'|; $form->{SHIPTO} = selectall_hashref_query($form, $dbh, $query, $vc_id); if ($vc eq 'customer') { diff --git a/SL/Controller/AccTrans.pm b/SL/Controller/AccTrans.pm index 80e37b7c1..b08c55f1e 100644 --- a/SL/Controller/AccTrans.pm +++ b/SL/Controller/AccTrans.pm @@ -17,7 +17,7 @@ sub action_list_transactions { my $acc_trans_table = $self->_mini_ledger($transactions); my $balances_table = $self->_mini_trial_balance($transactions); - return $self->render('acc_trans/acc_trans', { header => 0 }, acc_trans_table => $acc_trans_table, balances_table => $balances_table); + return $self->render('acc_trans/acc_trans', { layout => 0 }, acc_trans_table => $acc_trans_table, balances_table => $balances_table); } sub _mini_ledger { diff --git a/SL/Controller/Admin.pm b/SL/Controller/Admin.pm index c22fdca74..082b94ff8 100644 --- a/SL/Controller/Admin.pm +++ b/SL/Controller/Admin.pm @@ -119,7 +119,7 @@ sub action_new_user { countrycode => $defaults->language('de'), numberformat => $defaults->numberformat('1.000,00'), dateformat => $defaults->dateformat('dd.mm.yy'), - stylesheet => "kivitendo.css", + stylesheet => "design40.css", menustyle => "neu", }, )); @@ -137,6 +137,8 @@ sub action_save_user { my $params = delete($::form->{user}) || { }; my $props = delete($params->{config_values}) || { }; my $is_new = !$params->{id}; + my $check_previously_used = delete($::form->{check_previously_used}) || 0; + my $assign_documents = delete($::form->{assign_documents}) || 0; # Assign empty arrays if the browser doesn't send those controls. $params->{clients} ||= []; @@ -149,9 +151,29 @@ sub action_save_user { my @errors = $self->user->validate; if (@errors) { - flash('error', @errors); - $self->edit_user_form(title => $is_new ? t8('Create a new user') : t8('Edit User')); - return; + $self->js->flash('error', $_) foreach @errors; + return $self->js->render(); + } + + # check if given login name was previously used and show a dialog if so + if ($is_new && $check_previously_used && $self->check_loginname_previously_used()) { + $self->js->run('show_loginname_previously_used_dialog'); + return $self->js->render(); + } + + # rename previous usernames in employee table, if not set to assign + if ($is_new && !$assign_documents) { + my $clients = SL::DB::Manager::AuthClient->get_all_sorted; + for my $client (@$clients) { + my $now = DateTime->now_local; + my $timestamp = $now->format_cldr('yyyyMMddHHmmss'); + + my $dbh = $client->dbconnect(AutoCommit => 1); + next if !$dbh; + $dbh->do(qq|UPDATE employee SET login = ? WHERE login = ?;|,undef, + $params->{'login'} . $timestamp, $params->{'login'}); + $dbh->disconnect; + } } $self->user->save; @@ -505,7 +527,7 @@ sub init_all_groups { SL::DB::Manager::AuthGroup ->get_all_sorted sub init_all_printers { SL::DB::Manager::Printer ->get_all_sorted } sub init_all_dateformats { [ qw(mm/dd/yy dd/mm/yy dd.mm.yy yyyy-mm-dd) ] } sub init_all_numberformats { [ '1,000.00', '1000.00', '1.000,00', '1000,00', "1'000.00" ] } -sub init_all_stylesheets { [ qw(lx-office-erp.css Mobile.css kivitendo.css) ] } +sub init_all_stylesheets { [ qw(lx-office-erp.css Mobile.css kivitendo.css design40.css) ] } sub init_all_dbsources { [ sort User->dbsources($::form) ] } sub init_all_used_dbsources { { map { (join(':', $_->dbhost || 'localhost', $_->dbport || 5432, $_->dbname) => $_->name) } @{ $_[0]->all_clients } } } sub init_all_accounting_methods { [ { id => 'accrual', name => t8('Accrual accounting') }, { id => 'cash', name => t8('Cash accounting') } ] } @@ -592,13 +614,6 @@ sub use_multiselect_js { return $self; } -sub use_ckeditor_js { - my ($self) = @_; - - $::request->{layout}->use_javascript("${_}.js") for qw(ckeditor/ckeditor ckeditor/adapters/jquery); - return $self; -} - sub login_form { my ($self, %params) = @_; $::request->layout(SL::Layout::AdminLogin->new); @@ -608,7 +623,7 @@ sub login_form { sub edit_user_form { my ($self, %params) = @_; - $self->use_multiselect_js->use_ckeditor_js->render('admin/edit_user', %params); + $self->use_multiselect_js->render('admin/edit_user', %params); } sub edit_client_form { @@ -762,4 +777,22 @@ sub check_database_superuser_privileges { return (%result, error => $::locale->text('The database user \'#1\' does not have superuser privileges.', $result{username})); } +sub check_loginname_previously_used() { + my ($self) = @_; + + my $clients = SL::DB::Manager::AuthClient->get_all_sorted; + for my $client (@$clients) { + my $dbh = $client->dbconnect(); + next if !$dbh; + my ($result) = $dbh->selectrow_array(qq|SELECT login FROM employee WHERE login = ?;|,undef, + $self->user->{'login'}); + $dbh->disconnect; + + if ($result) { + return 1; + } + } + return 0; +} + 1; diff --git a/SL/Controller/BackgroundJob.pm b/SL/Controller/BackgroundJob.pm index 27a1c69a6..8941bb694 100644 --- a/SL/Controller/BackgroundJob.pm +++ b/SL/Controller/BackgroundJob.pm @@ -99,7 +99,7 @@ sub action_save_and_execute { my ($self) = @_; $self->background_job(SL::DB::BackgroundJob->new) if !$self->background_job; - return unless $self->create_or_update; + return unless $self->create_or_update(1); $self->action_execute; } @@ -170,6 +170,7 @@ sub create_or_update { if (@errors) { flash('error', @errors); + $self->setup_form_action_bar; $self->render('background_job/form', title => $is_new ? $::locale->text('Create a new background job') : $::locale->text('Edit background job')); return; } @@ -178,7 +179,7 @@ sub create_or_update { $self->background_job->save; flash_later('info', $is_new ? $::locale->text('The background job has been created.') : $::locale->text('The background job has been saved.')); - return if $return; + return 1 if $return; $self->redirect_to($self->back_to); } @@ -207,6 +208,7 @@ sub init_models { filtered => 0, sorted => { package_name => t8('Package name'), + description => t8('Description'), type => t8('Execution type'), active => t8('Active'), cron_spec => t8('Execution schedule'), diff --git a/SL/Controller/BackgroundJobHistory.pm b/SL/Controller/BackgroundJobHistory.pm index 4fbd7ece0..67d315a83 100644 --- a/SL/Controller/BackgroundJobHistory.pm +++ b/SL/Controller/BackgroundJobHistory.pm @@ -105,6 +105,7 @@ sub init_models { controller => $self, sorted => { package_name => t8('Package name'), + description => t8('Description'), run_at => t8('Run at'), status => t8('Execution status'), result => t8('Result'), diff --git a/SL/Controller/BankImport.pm b/SL/Controller/BankImport.pm index 1b20deca3..20c376418 100644 --- a/SL/Controller/BankImport.pm +++ b/SL/Controller/BankImport.pm @@ -137,7 +137,7 @@ sub import_transactions { next if $transaction->{error} || $transaction->{duplicate}; SL::DB::BankTransaction->new( - map { ($_ => $transaction->{$_}) } qw(amount currency_id local_bank_account_id purpose remote_account_number remote_bank_code remote_name transaction_code transdate valutadate) + map { ($_ => $transaction->{$_}) } qw(amount currency_id local_bank_account_id purpose remote_account_number remote_bank_code remote_name transaction_code transdate valutadate end_to_end_id) )->save; $imported++; @@ -170,16 +170,18 @@ sub make_transaction_idx { my ($transaction) = @_; if (ref($transaction) eq 'SL::DB::BankTransaction') { - $transaction = { map { ($_ => $transaction->$_) } qw(local_bank_account_id transdate valutadate amount purpose) }; + $transaction = { map { ($_ => $transaction->$_) } qw(local_bank_account_id remote_account_number transdate valutadate amount purpose end_to_end_id) }; } + my @other_fields = $transaction->{end_to_end_id} && $::instance_conf->get_check_bt_duplicates_endtoend + ? qw(end_to_end_id remote_account_number) : qw(purpose); return normalize_text(join '/', map { $_ // '' } ($transaction->{local_bank_account_id}, $transaction->{transdate}->ymd, $transaction->{valutadate}->ymd, (apply { s{0+$}{} } $transaction->{amount} * 1), - $transaction->{purpose})); + map { $transaction->{$_} } @other_fields)); } sub init_bank_accounts { diff --git a/SL/Controller/BankTransaction.pm b/SL/Controller/BankTransaction.pm index 9d432d556..2f14193d7 100644 --- a/SL/Controller/BankTransaction.pm +++ b/SL/Controller/BankTransaction.pm @@ -55,7 +55,9 @@ sub action_search { $self->setup_search_action_bar; $self->render('bank_transactions/search', - BANK_ACCOUNTS => $bank_accounts); + BANK_ACCOUNTS => $bank_accounts, + title => t8('Search bank transactions'), + ); } sub action_list_all { @@ -96,6 +98,9 @@ sub gather_bank_transactions_and_proposals { @where ], ); + + my $has_batch_transaction = (grep { $_->is_batch_transaction } @{ $bank_transactions }) ? 1 : undef; + # credit notes have a negative amount, treat differently my $all_open_ar_invoices = SL::DB::Manager::Invoice->get_all(where => [ or => [ amount => { gt => \'paid' }, # '} make emacs happy and => [ type => 'credit_note', @@ -107,37 +112,39 @@ sub gather_bank_transactions_and_proposals { my $all_open_ap_invoices = SL::DB::Manager::PurchaseInvoice->get_all(where => [amount => { ne => \'paid' }], # '}] make emacs happy with_objects => ['vendor' ,'payment_terms']); - my $all_open_sepa_export_items = SL::DB::Manager::SepaExportItem->get_all(where => [chart_id => $params{bank_account}->chart_id , - 'sepa_export.executed' => 0, - 'sepa_export.closed' => 0 - ], - with_objects => ['sepa_export']); my @all_open_invoices; # filter out invoices with less than 1 cent outstanding push @all_open_invoices, map { $_->{is_ar}=1 ; $_ } grep { abs($_->amount - $_->paid) >= 0.01 } @{ $all_open_ar_invoices }; push @all_open_invoices, map { $_->{is_ar}=0 ; $_ } grep { abs($_->amount - $_->paid) >= 0.01 } @{ $all_open_ap_invoices }; - my %sepa_exports; - my %sepa_export_items_by_id = partition_by { $_->ar_id || $_->ap_id } @$all_open_sepa_export_items; - - # first collect sepa export items to open invoices - foreach my $open_invoice (@all_open_invoices){ - $open_invoice->{realamount} = $::form->format_amount(\%::myconfig,$open_invoice->amount,2); - $open_invoice->{skonto_type} = 'without_skonto'; - foreach (@{ $sepa_export_items_by_id{ $open_invoice->id } || [] }) { - my $factor = ($_->ar_id == $open_invoice->id ? 1 : -1); - $open_invoice->{realamount} = $::form->format_amount(\%::myconfig,$open_invoice->amount*$factor,2); - - $open_invoice->{skonto_type} = $_->payment_type; - $sepa_exports{$_->sepa_export_id} ||= { count => 0, is_ar => 0, amount => 0, proposed => 0, invoices => [], item => $_ }; - $sepa_exports{$_->sepa_export_id}->{count}++; - $sepa_exports{$_->sepa_export_id}->{is_ar}++ if $_->ar_id == $open_invoice->id; - $sepa_exports{$_->sepa_export_id}->{amount} += $_->amount * $factor; - push @{ $sepa_exports{$_->sepa_export_id}->{invoices} }, $open_invoice; + + my (%sepa_exports, %sepa_export_items_by_id, $all_open_sepa_export_items); + if ($has_batch_transaction) { + $all_open_sepa_export_items = SL::DB::Manager::SepaExportItem->get_all(where => [chart_id => $params{bank_account}->chart_id , + 'sepa_export.executed' => 0, + 'sepa_export.closed' => 0 + ], + with_objects => ['sepa_export']); + %sepa_export_items_by_id = partition_by { $_->ar_id || $_->ap_id } @$all_open_sepa_export_items; + + # first collect sepa export items to open invoices + foreach my $open_invoice (@all_open_invoices){ + $open_invoice->{realamount} = $::form->format_amount(\%::myconfig,$open_invoice->amount,2); + $open_invoice->{skonto_type} = 'without_skonto'; + foreach (@{ $sepa_export_items_by_id{ $open_invoice->id } || [] }) { + my $factor = ($_->ar_id == $open_invoice->id ? 1 : -1); + $open_invoice->{realamount} = $::form->format_amount(\%::myconfig,$open_invoice->amount*$factor,2); + + $open_invoice->{skonto_type} = $_->payment_type; + $sepa_exports{$_->sepa_export_id} ||= { count => 0, is_ar => 0, amount => 0, proposed => 0, invoices => [], item => $_ }; + $sepa_exports{$_->sepa_export_id}->{count}++; + $sepa_exports{$_->sepa_export_id}->{is_ar}++ if $_->ar_id == $open_invoice->id; + $sepa_exports{$_->sepa_export_id}->{amount} += $_->amount * $factor; + push @{ $sepa_exports{$_->sepa_export_id}->{invoices} }, $open_invoice; + } } } - # try to match each bank_transaction with each of the possible open invoices # by awarding points my @proposals; @@ -152,7 +159,7 @@ sub gather_bank_transactions_and_proposals { $bt->{remote_name} .= $bt->{remote_name_1} if $bt->{remote_name_1}; - if ( $bt->is_batch_transaction ) { + if ($has_batch_transaction && $bt->is_batch_transaction ) { my $found=0; foreach ( keys %sepa_exports) { if ( abs(($sepa_exports{$_}->{amount} * 1) - ($bt->amount * 1)) < 0.01 ) { @@ -179,9 +186,9 @@ sub gather_bank_transactions_and_proposals { # score is stored in $bt->{agreement} foreach my $open_invoice (@all_open_invoices) { - ($open_invoice->{agreement}, $open_invoice->{rule_matches}) = $bt->get_agreement_with_invoice($open_invoice, - sepa_export_items => $all_open_sepa_export_items, - ); + + ($open_invoice->{agreement}, $open_invoice->{rule_matches}) = $bt->get_agreement_with_invoice($open_invoice); + $open_invoice->{realamount} = $::form->format_amount(\%::myconfig, $open_invoice->amount * ($open_invoice->{is_ar} ? 1 : -1), 2); } @@ -212,6 +219,7 @@ sub gather_bank_transactions_and_proposals { my @otherproposals = grep { ($_->{agreement} >= $proposal_threshold) && (1 == scalar @{ $_->{proposals} }) + && ($_->{proposals}->[0]->forex == 0) # nyi forex invoices for automatic booking } @{ $bank_transactions }; push @proposals, @otherproposals; @@ -247,13 +255,17 @@ sub action_list { sort_dir => $::form->{sort_dir}, ); + my $ui_tab = $::instance_conf->get_no_bank_proposals ? 0 + : scalar(@{ $proposals }) > 0 ? 1 + : 0; + $::request->layout->add_javascripts("kivi.BankTransaction.js"); $self->render('bank_transactions/list', title => t8('Bank transactions MT940'), BANK_TRANSACTIONS => $bank_transactions, PROPOSALS => $proposals, bank_account => $bank_account, - ui_tab => scalar(@{ $proposals }) > 0 ? 1 : 0, + ui_tab => $ui_tab, ); } @@ -300,7 +312,7 @@ sub action_create_invoice { )); # if we have exactly one ap match, use this directly - if (1 == scalar @{ $templates_ap }) { + if ($use_vendor_filter && 1 == scalar @{ $templates_ap }) { $self->redirect_to($self->load_ap_record_template_url($templates_ap->[0])); } else { @@ -429,7 +441,7 @@ sub action_ajax_add_list { my @all_open_invoices = @{ $all_open_ar_invoices }; # add ap invoices, filtering out subcent open amounts - push @all_open_invoices, grep { abs($_->amount - $_->paid) >= 0.01 } @{ $all_open_ap_invoices }; + push @all_open_invoices, grep { abs($_->amount - $_->paid) >= 0.005 } @{ $all_open_ap_invoices }; @all_open_invoices = sort { $a->id <=> $b->id } @all_open_invoices; @@ -512,8 +524,11 @@ sub save_invoices { push @{ $self->problems }, $self->save_single_bank_transaction( bank_transaction_id => $bank_transaction_id, invoice_ids => $invoice_ids, - sources => [ map { $::form->{"sources_${bank_transaction_id}_${_}"} } @{ $invoice_ids } ], - memos => [ map { $::form->{"memos_${bank_transaction_id}_${_}"} } @{ $invoice_ids } ], + sources => [ map { $::form->{"sources_${bank_transaction_id}_${_}"} } @{ $invoice_ids } ], + memos => [ map { $::form->{"memos_${bank_transaction_id}_${_}"} } @{ $invoice_ids } ], + book_fx_bank_fees => [ map { $::form->{"book_fx_bank_fees_${bank_transaction_id}_${_}"} } @{ $invoice_ids } ], + currency_ids => [ map { $::form->{"currency_id_${bank_transaction_id}_${_}"} } @{ $invoice_ids } ], + exchangerates => [ map { $::form->parse_amount(\%::myconfig, $::form->{"exchangerate_${bank_transaction_id}_${_}"}) } @{ $invoice_ids } ], ); $count += scalar( @{$invoice_ids} ); } @@ -581,11 +596,14 @@ sub save_single_bank_transaction { } my (@warnings); + my $transit_items_account = SL::DB::Manager::Chart->find_by(id => SL::DB::Default->get->transit_items_chart_id); + my $worker = sub { my $bt_id = $data{bank_transaction_id}; my $sign = $bank_transaction->amount < 0 ? -1 : 1; my $payment_received = $bank_transaction->amount > 0; my $payment_sent = $bank_transaction->amount < 0; + my ($has_negative_record, $has_positive_record); foreach my $invoice_id (@{ $params{invoice_ids} }) { @@ -597,9 +615,22 @@ sub save_single_bank_transaction { message => $::locale->text("The ID #1 is not a valid database ID.", $invoice_id), }; } + $has_positive_record = 1 if $invoice->amount > 0; # invoice + $has_negative_record = 1 if $invoice->amount < 0; # credit_note push @{ $data{invoices} }, $invoice; } + if (ref $transit_items_account eq 'SL::DB::Chart' && $has_positive_record + && scalar @{ $data{invoices} } == 2 && $has_negative_record) { + + $self->_check_and_book_credit_note( + invoices => $data{invoices}, + chart_id => $transit_items_account->id, + bt_id => $bt_id, + transdate => $bank_transaction->valutadate, + transit_chart => $transit_items_account ); + + } if ( $payment_received && any { ( $_->is_sales && ($_->amount < 0)) || (!$_->is_sales && ($_->amount > 0)) @@ -626,8 +657,11 @@ sub save_single_bank_transaction { my $n_invoices = 0; foreach my $invoice (@{ $data{invoices} }) { - my $source = ($data{sources} // [])->[$n_invoices]; - my $memo = ($data{memos} // [])->[$n_invoices]; + my $source = ($data{sources} // [])->[$n_invoices]; + my $memo = ($data{memos} // [])->[$n_invoices]; + my $fx_rate = ($data{exchangerates} // [])->[$n_invoices]; + my $fx_book = ($data{book_fx_bank_fees} // [])->[$n_invoices]; + my $currency_id = ($data{currency_ids} // [])->[$n_invoices]; $n_invoices++ ; # safety check invoice open @@ -644,12 +678,16 @@ sub save_single_bank_transaction { my ($payment_type, $free_skonto_amount); if ( defined $::form->{invoice_skontos}->{"$bt_id"} ) { - $payment_type = shift(@{ $::form->{invoice_skontos}->{"$bt_id"} }); + $payment_type = shift(@{ $::form->{invoice_skontos}->{"$bt_id"} }) || ''; } else { $payment_type = 'without_skonto'; } - - if ($payment_type eq 'free_skonto') { + # hack payment type use free_skonto for with_fuzzy_skonto + if ($payment_type eq 'with_fuzzy_skonto_pt') { + $free_skonto_amount = abs($invoice->open_amount - abs($bank_transaction->not_assigned_amount)); + die "Invalid state for fuzzy skonto amount" unless $free_skonto_amount > 0; + $payment_type = 'free_skonto'; # we have a valid free_skonto amount, therefore go ... + } elsif ($payment_type eq 'free_skonto') { # parse user input > 0 if ($::form->parse_amount(\%::myconfig, $::form->{"free_skonto_amount"}->{"$bt_id"}{$invoice->id}) > 0) { $free_skonto_amount = $::form->parse_amount(\%::myconfig, $::form->{"free_skonto_amount"}->{"$bt_id"}{$invoice->id}); @@ -663,26 +701,53 @@ sub save_single_bank_transaction { } # pay invoice # TODO rewrite this: really booked amount should be a return value of Payment.pm + # -> quick and dirty done -> really booked amount is the first element of return array # also this controller shouldnt care about how to calc skonto. we simply delegate the # payment_type to the helper and get the corresponding bank_transaction values back # hotfix to get the signs right - compare absolute values and later set the signs # should be better done elsewhere - changing not_assigned_amount to abs feels seriously bogus - + # default open amount my $open_amount = $payment_type eq 'with_skonto_pt' ? $invoice->amount_less_skonto : $invoice->open_amount; + # if fx calc new open amount with skonto pt and set new exchange rate (default or for bank_transaction) + if ($fx_rate > 0) { + # 1. set new open amount + die "Exchangerate without currency" unless $currency_id; + die "Invoice currency differs from user input currency" unless $currency_id == $invoice->currency->id; + $open_amount = $payment_type eq 'with_skonto_pt' ? $invoice->amount_less_skonto_fx($fx_rate) : $invoice->open_amount_fx($fx_rate); + # 2. set daily default or custom record exchange rate + my $default_rate = $invoice->get_exchangerate_for_bank_transaction($bank_transaction->id); + if (!$default_rate) { # set new daily default + my $buysell = $invoice->is_sales ? 'buy' : 'sell'; + my $ex = SL::DB::Manager::Exchangerate->find_by(currency_id => $currency_id, + transdate => $bank_transaction->valutadate) + || SL::DB::Exchangerate->new(currency_id => $currency_id, + transdate => $bank_transaction->valutadate); + $ex->update_attributes($buysell => $fx_rate); + $bank_transaction->exchangerate(undef); # maybe user reassigned bank_transaction + } elsif ($default_rate != $fx_rate) { # set record (banktransaction) exchangerate + $bank_transaction->exchangerate($fx_rate); # custom rate, will be displayed in ap, ir, is + } elsif (abs($default_rate - $fx_rate) < 0.001) { + # last valid state default rate is (nearly) the same as user input -> do nothing + } else { die "Invalid exchange rate state:" . $default_rate . " " . $fx_rate; } + } # end fx hook + + # open amount is in default currency -> free_skonto is in default currency, no need to change $open_amount = abs($open_amount); $open_amount -= $free_skonto_amount if ($payment_type eq 'free_skonto'); my $not_assigned_amount = abs($bank_transaction->not_assigned_amount); my $amount_for_booking = ($open_amount < $not_assigned_amount) ? $open_amount : $not_assigned_amount; + my $fx_fee_amount = $fx_book && ($open_amount < $not_assigned_amount) ? $not_assigned_amount - $open_amount : 0; my $amount_for_payment = $amount_for_booking; + # add booking amount + # $amount_for_booking # get the right direction for the payment bookings (all amounts < 0 are stornos, credit notes or negative ap) $amount_for_payment *= -1 if $invoice->amount < 0; $free_skonto_amount *= -1 if ($free_skonto_amount && $invoice->amount < 0); # get the right direction for the bank transaction + # sign is simply the sign of amount in bank_transactions: positive for increase and negative for decrease $amount_for_booking *= $sign; - $bank_transaction->invoice_amount($bank_transaction->invoice_amount + $amount_for_booking); - # ... and then pay the invoice my @acc_ids = $invoice->pay_invoice(chart_id => $bank_transaction->local_bank_account->chart_id, trans_id => $invoice->id, @@ -691,8 +756,24 @@ sub save_single_bank_transaction { source => $source, memo => $memo, skonto_amount => $free_skonto_amount, + exchangerate => $fx_rate, + fx_book => $fx_book, + fx_fee_amount => $fx_fee_amount, + currency_id => $currency_id, bt_id => $bt_id, transdate => $bank_transaction->valutadate->to_kivitendo); + # First element is the booked amount for accno bank + my $bank_amount = shift @acc_ids; + + if (!$invoice->forex) { + # die "Invalid state, calculated invoice_amount differs from expected invoice amount" unless (abs($bank_amount->{return_bank_amount}) - abs($amount_for_booking) < 0.001); + $bank_transaction->invoice_amount($bank_transaction->invoice_amount + $amount_for_booking); + } else { + die "Invalid state, calculated invoice_amount differs from expected invoice amount: $amount_for_booking <> " . $bank_amount->{return_bank_amount} + unless $fx_book || (abs($bank_amount->{return_bank_amount}) - abs($amount_for_booking) < 0.005); + $bank_transaction->invoice_amount($bank_transaction->invoice_amount + $bank_amount->{return_bank_amount}); + #$bank_transaction->invoice_amount($bank_transaction->invoice_amount + $amount_for_booking); + } # ... and record the origin via BankTransactionAccTrans if (scalar(@acc_ids) < 2) { return { @@ -806,16 +887,25 @@ sub action_unlink_bank_transaction { my $query = qq|UPDATE $type SET paid = (SELECT COALESCE(abs(sum(amount)),0) FROM acc_trans WHERE trans_id = ? - AND chart_link ilike '%paid%') - WHERE id = ?|; + AND (chart_link ilike '%paid%' + OR chart_id IN (SELECT fxgain_accno_id from defaults) + OR chart_id IN (SELECT fxloss_accno_id from defaults) + ) + ) + WHERE id = ?|; die if (do_query($::form, $bank_transaction->db->dbh, $query, $trans_id, $trans_id) == -1); + + # undo datepaid if no payment exists + $query = qq|UPDATE $type SET datepaid = null WHERE ID = ? AND paid = 0|; + die if (do_query($::form, $bank_transaction->db->dbh, $query, $trans_id) == -1); } # 4. and delete all (if any) record links my $rl = SL::DB::Manager::RecordLink->delete_all(where => [ from_id => $bt_id, from_table => 'bank_transactions' ]); # 5. finally reset this bank transaction $bank_transaction->invoice_amount(0); + $bank_transaction->exchangerate(undef); $bank_transaction->cleared(0); $bank_transaction->save; # 6. and add a log entry in history_erp @@ -863,6 +953,9 @@ sub make_filter_summary { [ $filter->{"amount:number"}, $::locale->text('Amount') ], [ $filter->{"bank_account_id:integer"}, $::locale->text('Local bank account') ], [ $filter->{"remote_name:substr::ilike"}, $::locale->text('Remote name') ], + [ $filter->{"remote_account_number:substr::ilike"}, $::locale->text('Remote account number') ], + [ $filter->{"remote_bank_code:substr::ilike"} , $::locale->text('Remote bank code') ], + [ $filter->{"purpose:substr::ilike"} , $::locale->text('Purpose') ], ); for (@filters) { @@ -873,17 +966,18 @@ sub make_filter_summary { } sub prepare_report { - my ($self) = @_; + my ($self) = @_; - my $callback = $self->models->get_callback; + my $callback = $self->models->get_callback; - my $report = SL::ReportGenerator->new(\%::myconfig, $::form); - $self->{report} = $report; + my $report = SL::ReportGenerator->new(\%::myconfig, $::form); + $report->{title} = t8('Bank transactions'); + $self->{report} = $report; - my @columns = qw(ids local_bank_name transdate valudate remote_name remote_account_number remote_bank_code amount invoice_amount invoices currency purpose local_account_number local_bank_code id); - my @sortable = qw(local_bank_name transdate valudate remote_name remote_account_number remote_bank_code amount purpose local_account_number local_bank_code); + my @columns = qw(ids local_bank_name transdate valudate remote_name remote_account_number remote_bank_code amount invoice_amount invoices currency purpose end_to_end_id local_account_number local_bank_code id); + my @sortable = qw(local_bank_name transdate valudate remote_name remote_account_number remote_bank_code amount purpose local_account_number local_bank_code); - my %column_defs = ( + my %column_defs = ( ids => { raw_header_data => checkbox_tag("", id => "check_all", checkall => "[data-checkall=1]"), 'align' => 'center', raw_data => sub { if (@{ $_[0]->linked_invoices }) { @@ -902,13 +996,23 @@ sub prepare_report { align => 'right' }, invoice_amount => { sub => sub { $_[0]->invoice_amount_as_number }, align => 'right' }, - invoices => { sub => sub { my @invnumbers; for my $obj (@{ $_[0]->linked_invoices }) { - next unless $obj; push @invnumbers, $obj->invnumber } return \@invnumbers } }, + invoices => { sub => sub { my @invnumbers; for my $obj (@{ $_[0]->linked_invoices }) { + next unless $obj; push @invnumbers, $obj->invnumber } return \@invnumbers }, + obj_link => sub { my @links; for my $obj (@{ $_[0]->linked_invoices }) { + next unless $obj; my $script = ref $obj eq 'SL::DB::GLTransaction' ? 'gl.pl' + : $obj->is_sales && $obj->invoice ? 'is.pl' + : $obj->is_sales && !$obj->invoice ? 'ar.pl' + : !$obj->is_sales && $obj->invoice ? 'ir.pl' + : !$obj->is_sales && !$obj->invoice ? 'ap.pl' + : die "Invalid invoice state for link"; + push @links,$script . "?action=edit&id=" . $obj->id } return \@links } + }, currency => { sub => sub { $_[0]->currency->name } }, purpose => { }, local_account_number => { sub => sub { $_[0]->local_bank_account->account_number } }, local_bank_code => { sub => sub { $_[0]->local_bank_account->bank_code } }, local_bank_name => { sub => sub { $_[0]->local_bank_account->name } }, + end_to_end_id => { sub => sub { $_[0]->end_to_end_id }, text => $::locale->text('End to end ID') }, id => {}, ); @@ -938,6 +1042,101 @@ sub prepare_report { ); } +sub _check_and_book_credit_note { + my $self = shift; + my %params = @_; + Common::check_params(\%params, qw(chart_id transdate bt_id invoices transit_chart)); + + croak "No invoice " unless (ref $params{invoices}->[0] eq 'SL::DB::PurchaseInvoice') + || (ref $params{invoices}->[0] eq 'SL::DB::Invoice' ); + croak "Not a valid date" unless ref $params{transdate} eq 'DateTime'; + croak "Not a valid chart" unless ref $params{transit_chart} eq 'SL::DB::Chart'; + croak "Need exactly two records" unless scalar @{ $params{invoices} } == 2; + + + my ($has_one_credit_note, $has_one_invoice, $amount, $credit_note_index, $credit_note_no, $invoice_no); + my $index = 0; + foreach my $invoice (@{ $params{invoices} }) { + if ( ( $invoice->is_sales && $invoice->type eq 'credit_note') + || (!$invoice->is_sales && $invoice->invoice_type eq 'purchase_credit_note')) { + # credit_notes | purchase_credit_note + # -1397.11000 | AR | 504.74000 | AP + # 1397.11000 | AR_paid | -504.74000 | AP_paid + + my $mult = $invoice->is_sales ? -1 : 1; # multiplier for getting the right sign for credit_notes + $amount = ($invoice->amount - $invoice->paid) * $mult; + # (-200 - (-10)) * $mult = AR_paid (positive) |AP_paid (negative) + + $has_one_credit_note += 1; + $credit_note_index = $index; + $credit_note_no = $invoice->invnumber; + } else { + $has_one_invoice += 1; + $invoice_no = $invoice->invnumber; + } + $index++; + } + die "Invalid state" unless ($has_one_credit_note == 1 && $has_one_invoice == 1); + + foreach my $invoice (@{ $params{invoices} }) { + my $is_credit_note = $invoice->is_credit_note ? 1 : undef; + my $sign = $invoice->is_credit_note ? 1 : -1; # correct sign for bookings + my $paid_sign = $invoice->is_credit_note ? -1 : 1; # paid is always negative for credit_note + + my $new_acc_trans = SL::DB::AccTransaction->new(trans_id => $invoice->id, + chart_id => $params{transit_chart}->id, + chart_link => $params{transit_chart}->link, + amount => $amount * $sign, + transdate => $params{transdate}, + source => $is_credit_note ? $invoice_no : $credit_note_no, + memo => t8('Automatically assigned with bank transaction'), + taxkey => 0, + tax_id => SL::DB::Manager::Tax->find_by(taxkey => 0)->id); + + my $arap_booking= SL::DB::AccTransaction->new(trans_id => $invoice->id, + chart_id => $invoice->reference_account->id, + chart_link => $invoice->reference_account->link, + amount => $amount * $sign * -1, + transdate => $params{transdate}, + source => '', + taxkey => 0, + tax_id => SL::DB::Manager::Tax->find_by(taxkey => 0)->id); + $new_acc_trans->save; + $arap_booking->save; + $invoice->update_attributes(paid => $invoice->paid + (abs($amount) * $paid_sign), datepaid => $params{transdate}); + + # link both acc_trans transactions + my $id_type = $invoice->is_sales ? 'ar' : 'ap'; + my %props_acc = ( + acc_trans_id => $new_acc_trans->acc_trans_id, + bank_transaction_id => $params{bt_id}, + $id_type => $invoice->id, + ); + SL::DB::BankTransactionAccTrans->new(%props_acc)->save; + %props_acc = ( + acc_trans_id => $arap_booking->acc_trans_id, + bank_transaction_id => $params{bt_id}, + $id_type => $invoice->id, + ); + SL::DB::BankTransactionAccTrans->new(%props_acc)->save; + # done + + # Record a record link from the bank transaction to the credit note + if ($invoice->invoice_type =~ m/credit_note/) { + my %props = ( + from_table => 'bank_transactions', + from_id => $params{bt_id}, + to_table => $id_type, + to_id => $invoice->id, + ); + SL::DB::RecordLink->new(%props)->save; + } + } + # throw away the credit note + splice @{ $params{invoices} }, $credit_note_index, 1; + # and return nothing. hook is completely done +} + sub init_problems { [] } sub init_models { @@ -1135,6 +1334,18 @@ purpose string before it will be assigned to the description field of a gl transaction or to the notes field of an ap transaction. You have to write your own custom code. +=item C<_check_and_book_credit_note> + +This method takes a array of invoices with two entries one one valid credit note +and books the amount of the credit note against the invoice via the default +transfer items account (i.e. SKR04 1370) and adds a source and memo entry for the +payment booking. +Logical and visual linking of the payment booking and credit note record to the bank +transaction will also be done (necessary cond. for unlinking a bank transaction). +If the methods success the credit note will be deleted from +the original caller's array and he can further process the data without pondering +about the removed credit note data. + =back =head1 AUTHOR diff --git a/SL/Controller/Base.pm b/SL/Controller/Base.pm index 63f153fa8..3c5f919de 100644 --- a/SL/Controller/Base.pm +++ b/SL/Controller/Base.pm @@ -156,6 +156,7 @@ sub send_file { } my $content_type = $params{type} || 'application/octet_stream'; + my $content_disposition = $params{content_disposition} || 'attachment'; my $attachment_name = $params{name} || (!ref($file_name_or_content) ? $file_name_or_content : ''); $attachment_name =~ s:.*//::g; @@ -165,7 +166,7 @@ sub send_file { $self->js->render unless $params{js_no_render}; } else { print $::form->create_http_response(content_type => $content_type, - content_disposition => 'attachment; filename="' . $attachment_name . '"', + content_disposition => $content_disposition . '; filename="' . $attachment_name . '"', content_length => $size); if (!ref $file_name_or_content) { diff --git a/SL/Controller/Buchungsgruppen.pm b/SL/Controller/Buchungsgruppen.pm index a608a6eab..c108dc1c1 100644 --- a/SL/Controller/Buchungsgruppen.pm +++ b/SL/Controller/Buchungsgruppen.pm @@ -33,10 +33,12 @@ sub action_list { $chartlist{ $gruppe->id } = SL::DB::TaxzoneChart->get_all_accounts_by_buchungsgruppen_id($gruppe->id); } + my $title = t8('Booking groups'); $self->setup_list_action_bar; + $::form->{title} = $title; $::form->header; $self->render('buchungsgruppen/list', - title => t8('Booking groups'), + title => $title, BUCHUNGSGRUPPEN => $buchungsgruppen, CHARTLIST => \%chartlist, TAXZONES => $taxzones); @@ -142,9 +144,7 @@ sub create_or_update { @errors = $self->config->validate; # check for description and inventory_accno_id - if (@errors) { - die "foo" . @errors . "\n"; - }; + return 0 if @errors; $self->config->save; @@ -175,7 +175,8 @@ sub create_or_update { 1; })) { - die @errors ? join("\n", @errors) . "\n" : $db->error . "\n"; + my $error = @errors ? join("\n", @errors) . "\n" : $db->error . "\n"; + $::form->show_generic_error($error); # die with rollback of taxzone save if saving of any of the taxzone_charts fails # only show the $db->error if we haven't already identified the likely error ourselves } diff --git a/SL/Controller/Chart.pm b/SL/Controller/Chart.pm index eb2b4739a..d6656d7a0 100644 --- a/SL/Controller/Chart.pm +++ b/SL/Controller/Chart.pm @@ -87,7 +87,6 @@ sub action_show { sub action_show_report_configuration_overview { my ($self) = @_; - my @all_charts = sort { $a->accno cmp $b->accno } @{ SL::DB::Manager::Chart->get_all(inject_results => 1) }; my @types = qw(bilanz bwa er eur); my %headings = ( @@ -118,7 +117,7 @@ sub action_show_report_configuration_overview { }; } - $self->render('chart/report_configuration_overview', DATA => \@data); + $self->render('chart/report_configuration_overview', DATA => \@data, title => t8('Chart configuration overview regarding reports')); } sub init_charts { diff --git a/SL/Controller/ChartOfAccounts.pm b/SL/Controller/ChartOfAccounts.pm new file mode 100644 index 000000000..5f058afc4 --- /dev/null +++ b/SL/Controller/ChartOfAccounts.pm @@ -0,0 +1,158 @@ +package SL::Controller::ChartOfAccounts; + +use strict; +use parent qw(SL::Controller::Base); + +use POSIX qw(strftime); + +use SL::CA; + +use SL::ReportGenerator; +use SL::Controller::Helper::ReportGenerator; +use SL::Locale::String; + +use Rose::Object::MakeMethods::Generic ( + scalar => [ qw(report) ], +); + +__PACKAGE__->run_before(sub { $::auth->assert('report'); }); + +sub action_list { + my ($self) = @_; + + if ( $::instance_conf->get_accounting_method eq 'cash' ) { + $::form->{method} = "cash"; + } + + $self->prepare_report; + $self->set_report_data; + $self->report->generate_with_headers; +} + +# private functions + +sub prepare_report { + my ($self) = @_; + + $self->report(SL::ReportGenerator->new(\%::myconfig, $::form)); + + $self->report->{title} = t8 ('Chart of Accounts'); + my @columns = qw(accno description debit credit); + my %column_defs = ( + accno => { text => t8('Account') }, + description => { text => t8('Description') }, + debit => { text => t8('Debit') }, + credit => { text => t8('Credit') }, + ); + + $self->report->set_options( + std_column_visibility => 1, + controller_class => 'ChartOfAccounts', + output_format => 'HTML', + title => t8('Chart of Accounts'), + allow_pdf_export => 1, + allow_csv_export => 1, + attachment_basename => t8('chart_of_accounts') . strftime('_%Y%m%d', localtime time), + ); + $self->report->set_columns(%column_defs); + $self->report->set_column_order(@columns); + + $self->report->set_export_options(qw(list)); + $self->report->set_options_from_form; + $self->report->set_sort_indicator($::form->{sort}, 1); +} + +sub set_report_data { + my ($self) = @_; + + my $debit_sum = 0.; + my $credit_sum = 0.; + + # i tried to use the get_balance function from SL::DB::Manager::Chart here, + # but the results i got were different (numbers and defined balance/amount), + # the database queries in CA are more sophisticated, therefore i'm still using these for now, + # also performance wise they seem faster + CA->all_accounts(\%::myconfig, \%$::form); + + my $formatted_zero = $::form->format_amount(\%::myconfig, 0., 2); + + for my $chart (@{ $::form->{CA} }) { + my $balance = $chart->{amount}; + + my $link = "controller.pl?action=ListTransactions%2freport_settings&accno=$chart->{accno}&link=1"; + if (defined($balance)) { + my %data = ( + accno => { data => $chart->{accno}, link => $link }, + description => { data => $chart->{description} }, + debit => { data => $balance < 0 ? $::form->format_amount(\%::myconfig, $balance * -1., 2) : ''}, + credit => { data => $balance >= 0 ? $::form->format_amount(\%::myconfig, $balance, 2) : ''}, + ); + $data{$_}->{align} = 'right' for qw(debit credit); + map { $data{$_}->{class} = 'listheading' } keys %data if ($chart->{charttype} eq "H") ; + $self->report->add_data(\%data); + + if ($balance < 0) { + $debit_sum += $balance; + } else { + $credit_sum += $balance; + } + } + } + my %data_total = ( + accno => { data => t8('Total') }, + description => { data => '' }, + debit => { data => $::form->format_amount(\%::myconfig, $debit_sum * -1., 2)}, + credit => { data => $::form->format_amount(\%::myconfig, $credit_sum, 2)}, + ); + $data_total{$_}->{align} = 'right' for qw(debit credit); + $data_total{$_}->{class} = 'listtotal' for keys %data_total; + + $self->report->add_data(\%data_total); +} + +1; + +__END__ + +=encoding utf-8 + +=head1 NAME + +SL::Controller::ChartOfAccounts - Controller for the chart of accounts report + +=head1 SYNOPSIS + +New controller for Reports -> Chart of Accounts. + +This replaces the old bin/mozilla/ca.pl chart_of_accounts sub. + +The rest of the functions from ca.pl are separated into the new ListTransactions.pm +controller. + +=head1 DESCRIPTION + +Displays a list of all accounts with their balance. + +Clicking on an account number will open the form for Reports -> List Transactions, with +the account number preselected. + +Export to PDF, CSV and Chart is possible. + +=head1 CAVEATS / TODO + +Database queries are still from SL::CA. + +I tried to use the get_balance function from SL::DB::Manager::Chart here, +but the results i got were different (numbers and defined balance/amount). +The database queries in CA are more sophisticated, therefore i'm still using these for now. +Also performance wise they seem faster. + +=head1 BUGS + +None yet. + +=head1 AUTHOR + +Cem Aydin Ecem.aydin@revamp-it.chE + +=cut diff --git a/SL/Controller/ClientConfig.pm b/SL/Controller/ClientConfig.pm index bc0f28701..94d81d3d5 100644 --- a/SL/Controller/ClientConfig.pm +++ b/SL/Controller/ClientConfig.pm @@ -17,19 +17,21 @@ use SL::Helper::Flash; use SL::Locale::String qw(t8); use SL::PriceSource::ALL; use SL::Template; +use SL::DB::Order::TypeData; +use SL::DB::DeliveryOrder::TypeData; +use SL::DB::Reclamation::TypeData; use SL::Controller::TopQuickSearch; use SL::DB::Helper::AccountingPeriod qw(get_balance_startdate_method_options); -use SL::Helper::ShippedQty; use SL::VATIDNr; use SL::ZUGFeRD; __PACKAGE__->run_before('check_auth'); use Rose::Object::MakeMethods::Generic ( - 'scalar --get_set_init' => [ qw(defaults all_warehouses all_weightunits all_languages all_currencies all_templates all_price_sources h_unit_name available_quick_search_modules available_shipped_qty_item_identity_fields + 'scalar --get_set_init' => [ qw(defaults all_warehouses all_weightunits all_languages all_currencies all_templates all_price_sources h_unit_name available_quick_search_modules all_project_statuses all_project_types zugferd_settings - posting_options payment_options accounting_options inventory_options profit_options balance_startdate_method_options - displayable_name_specs_by_module) ], + posting_options payment_options accounting_options inventory_options profit_options balance_startdate_method_options yearend_options + displayable_name_specs_by_module available_documents_with_no_positions) ], ); sub action_edit { @@ -53,8 +55,6 @@ sub action_save { undef $defaults->{$_} if !$defaults->{$_}; } - $defaults->{$_} = $::form->parse_amount(\%::myconfig, $defaults->{$_}) for qw(customer_hourly_rate); - $self->defaults->assign_attributes(%{ $defaults }); my %errors_idx; @@ -77,13 +77,21 @@ sub action_save { $existing_currency->name($new_name); } } - if ($::form->{new_currency} && $new_currency_names{ $::form->{new_currency} }) { $errors_idx{1} = t8('Currency names must be unique.'); } my @errors = map { $errors_idx{$_} } sort keys %errors_idx; + # check valid mail adresses + foreach (qw(email_sender_sales_quotation email_sender_request_quotation email_sender_sales_order + email_sender_purchase_order email_sender_sales_delivery_order email_sender_purchase_delivery_order + email_sender_invoice email_sender_purchase_invoice email_sender_letter email_sender_dunning + global_bcc)) { + next unless $defaults->{$_}; + next if $defaults->{$_} =~ /^[a-z0-9.]+\@[a-z0-9.-]+$/i; + push @errors, t8('The email entry for #1 looks invalid', $_); + } # Check templates $::form->{new_templates} =~ s:/::g; $::form->{new_master_templates} =~ s:/::g; @@ -200,6 +208,11 @@ sub init_balance_startdate_method_options { return SL::DB::Helper::AccountingPeriod::get_balance_startdate_method_options; } +sub init_yearend_options { + [ { title => t8("default"), value => "default" }, + { title => t8("simple"), value => "simple" }, ] +} + sub init_all_price_sources { my @classes = SL::PriceSource::ALL->all_price_sources; @@ -210,10 +223,6 @@ sub init_available_quick_search_modules { [ SL::Controller::TopQuickSearch->new->available_modules ]; } -sub init_available_shipped_qty_item_identity_fields { - [ SL::Helper::ShippedQty->new->available_item_identity_fields ]; -} - sub init_displayable_name_specs_by_module { +{ 'SL::DB::Customer' => { @@ -231,6 +240,16 @@ sub init_displayable_name_specs_by_module { }; } +sub init_available_documents_with_no_positions { + my @docs = ( @{SL::DB::Order::TypeData::valid_types()}, + @{SL::DB::DeliveryOrder::TypeData::valid_types()}, + @{SL::DB::Reclamation::TypeData::valid_types()} ); + + my @available_docs = map { {name => $_, description => $::form->get_formname_translation($_)} } @docs; + + return \@available_docs; +} + # # filters # @@ -246,7 +265,7 @@ sub check_auth { sub edit_form { my ($self) = @_; - $::request->layout->use_javascript("${_}.js") for qw(jquery.selectboxes jquery.multiselect2side kivi.File ckeditor/ckeditor ckeditor/adapters/jquery); + $::request->layout->use_javascript("${_}.js") for qw(jquery.selectboxes jquery.multiselect2side kivi.File); $self->setup_edit_form_action_bar; $self->render('client_config/form', title => t8('Client Configuration'), diff --git a/SL/Controller/CsvImport.pm b/SL/Controller/CsvImport.pm index d1ff7199f..d35419719 100644 --- a/SL/Controller/CsvImport.pm +++ b/SL/Controller/CsvImport.pm @@ -23,6 +23,7 @@ use SL::Controller::CsvImport::Project; use SL::Controller::CsvImport::Order; use SL::Controller::CsvImport::DeliveryOrder; use SL::Controller::CsvImport::ARTransaction; +use SL::Controller::CsvImport::APTransaction; use SL::JSON; use SL::Controller::CsvImport::BankTransaction; use SL::BackgroundJob::CsvImport; @@ -105,7 +106,11 @@ sub action_result { $self->profile($profile); if ($data->{errors} and my $first_error = $data->{errors}->[0]) { - flash('error', $::locale->text('There was an error parsing the csv file: #1 in line #2.', $first_error->[2], $first_error->[4])); + if ('ARRAY' eq ref $first_error) { + flash('error', $::locale->text('There was an error parsing the csv file: #1 in line #2.', $first_error->[2], $first_error->[4])); + } else { + flash('error', $::locale->text('There was an error parsing the csv file: #1', $first_error)); + } } if ($data->{progress}{finished} || $data->{errors}) { @@ -308,7 +313,7 @@ sub check_auth { sub check_type { my ($self) = @_; - die "Invalid CSV import type" if none { $_ eq $::form->{profile}->{type} } qw(parts inventories customers_vendors billing_addresses addresses contacts projects orders delivery_orders bank_transactions ar_transactions); + die "Invalid CSV import type" if none { $_ eq $::form->{profile}->{type} } qw(parts inventories customers_vendors billing_addresses addresses contacts projects orders delivery_orders bank_transactions ar_transactions ap_transactions); $self->type($::form->{profile}->{type}); } @@ -359,9 +364,10 @@ sub render_inputs { : $self->type eq 'delivery_orders' ? $::locale->text('CSV import: delivery orders') : $self->type eq 'bank_transactions' ? $::locale->text('CSV import: bank transactions') : $self->type eq 'ar_transactions' ? $::locale->text('CSV import: ar transactions') + : $self->type eq 'ap_transactions' ? $::locale->text('CSV import: ap transactions') : die; - if ( any { $_ eq $self->{type} } qw(customers_vendors orders delivery_orders ar_transactions) ) { + if ( any { $_ eq $self->{type} } qw(customers_vendors orders delivery_orders ar_transactions ap_transactions) ) { $self->all_taxzones(SL::DB::Manager::TaxZone->get_all_sorted(query => [ obsolete => 0 ])); }; @@ -510,7 +516,7 @@ sub profile_from_form { $::form->{settings}->{sellprice_adjustment} = $::form->parse_amount(\%::myconfig, $::form->{settings}->{sellprice_adjustment}); } - if ($self->type eq 'orders' or $self->{type} eq 'ar_transactions') { + if ( any { $_ eq $self->{type} } qw(orders ar_transactions ap_transactions) ) { $::form->{settings}->{max_amount_diff} = $::form->parse_amount(\%::myconfig, $::form->{settings}->{max_amount_diff}); } @@ -731,6 +737,7 @@ sub init_worker { : $self->{type} eq 'delivery_orders' ? SL::Controller::CsvImport::DeliveryOrder->new(@args) : $self->{type} eq 'bank_transactions' ? SL::Controller::CsvImport::BankTransaction->new(@args) : $self->{type} eq 'ar_transactions' ? SL::Controller::CsvImport::ARTransaction->new(@args) + : $self->{type} eq 'ap_transactions' ? SL::Controller::CsvImport::APTransaction->new(@args) : die "Program logic error"; } diff --git a/SL/Controller/CsvImport/APTransaction.pm b/SL/Controller/CsvImport/APTransaction.pm new file mode 100644 index 000000000..57ae37f15 --- /dev/null +++ b/SL/Controller/CsvImport/APTransaction.pm @@ -0,0 +1,643 @@ +package SL::Controller::CsvImport::APTransaction; + +use strict; + +use List::MoreUtils qw(any); + +use SL::Helper::Csv; +use SL::Controller::CsvImport::Helper::Consistency; +use SL::DB::PurchaseInvoice; +use SL::DB::AccTransaction; +use SL::DB::Department; +use SL::DB::Project; +use SL::DB::TaxZone; +use SL::DB::Chart; +use SL::TransNumber; +use DateTime; + +use parent qw(SL::Controller::CsvImport::BaseMulti); + +use Rose::Object::MakeMethods::Generic +( + 'scalar --get_set_init' => [ qw(settings charts_by taxkeys_by) ], +); + + +sub init_class { + my ($self) = @_; + $self->class(['SL::DB::PurchaseInvoice', 'SL::DB::AccTransaction']); +} + +sub set_profile_defaults { + my ($self) = @_; + + $self->controller->profile->_set_defaults( + ap_column => $::locale->text('Invoice'), + transaction_column => $::locale->text('AccTransaction'), + max_amount_diff => 0.02, + dont_save_anything_on_errors => 0, + ); +}; + + +sub init_settings { + my ($self) = @_; + + return { map { ( $_ => $self->controller->profile->get($_) ) } qw(ap_column transaction_column max_amount_diff dont_save_anything_on_errors) }; +} + +sub init_profile { + my ($self) = @_; + + my $profile = $self->SUPER::init_profile; + + # SUPER::init_profile sets row_ident to the translated class name + # overwrite it with the user specified settings + foreach my $p (@{ $profile }) { + if ($p->{class} eq 'SL::DB::PurchaseInvoice') { + $p->{row_ident} = $self->_ap_column; + } + if ($p->{class} eq 'SL::DB::AccTransaction') { + $p->{row_ident} = $self->_transaction_column; + } + } + + foreach my $p (@{ $profile }) { + my $prof = $p->{profile}; + if ($p->{row_ident} eq $self->_ap_column) { + # no need to handle + delete @{$prof}{qw(amount cp_id datepaid delivery_term_id gldate invoice language_id netamount paid shipvia storno storno_id taxzone taxzone_id type)}; + } + if ($p->{row_ident} eq $self->_transaction_column) { + # no need to handle + delete @{$prof}{qw(acc_trans_id cb_transaction chart_link cleared fx_transaction gldate memo ob_transaction source tax_id description trans_id transdate)}; + } + } + + return $profile; +} + +sub init_existing_objects { + my ($self) = @_; + + # only use objects of main class (the first one) + #eval "require " . $self->class->[0]; + $self->existing_objects($self->manager_class->[0]->get_all); +} + +sub get_duplicate_check_fields { + return { + vendor_and_invnumber => { + label => $::locale->text('Vendor and Invoice Number'), + default => 1, + std_check => 1, + maker => sub { + my ($object, $worker) = @_; + return if ref $object ne $worker->class->[0]; + return '__' . $object->vendor_id . '__' . $object->invnumber . '__'; + }, + }, + }; +} + +sub check_std_duplicates { + my $self = shift; + + my $duplicates = {}; + + my $all_fields = $self->get_duplicate_check_fields(); + + foreach my $key (keys(%{ $all_fields })) { + if ( $self->controller->profile->get('duplicates_'. $key) && (!exists($all_fields->{$key}->{std_check}) || $all_fields->{$key}->{std_check} ) ) { + $duplicates->{$key} = {}; + } + } + + my @duplicates_keys = keys(%{ $duplicates }); + + if ( !scalar(@duplicates_keys) ) { + return; + } + + if ( $self->controller->profile->get('duplicates') eq 'check_db' ) { + foreach my $object (@{ $self->existing_objects }) { + foreach my $key (@duplicates_keys) { + my $value = exists($all_fields->{$key}->{maker}) ? $all_fields->{$key}->{maker}->($object, $self) : $object->$key; + $duplicates->{$key}->{$value} = 'db'; + } + } + } + + # only check main class (the first one) + foreach my $entry (@{ $self->controller->data }) { + my $object = $entry->{object}; + + next if ref $object ne $self->class->[0]; + next if scalar @{ $entry->{errors} }; + + foreach my $key (@duplicates_keys) { + my $value = exists($all_fields->{$key}->{maker}) ? $all_fields->{$key}->{maker}->($object, $self) : $object->$key; + + if ( exists($duplicates->{$key}->{$value}) ) { + push(@{ $entry->{errors} }, $duplicates->{$key}->{$value} eq 'db' ? $::locale->text('Duplicate in database') : $::locale->text('Duplicate in CSV file')); + last; + } else { + $duplicates->{$key}->{$value} = 'csv'; + } + } + } +} + +sub setup_displayable_columns { + my ($self) = @_; + + $self->SUPER::setup_displayable_columns; + + $self->add_displayable_columns($self->_ap_column, + { name => 'datatype', description => $self->_ap_column . ' [1]' }, + { name => 'currency_id', description => $::locale->text('Currency (database ID)') }, + { name => 'currency', description => $::locale->text('Currency') }, + { name => 'deliverydate', description => $::locale->text('Delivery Date') }, + { name => 'department_id', description => $::locale->text('Department (database ID)') }, + { name => 'department', description => $::locale->text('Department (description)') }, + { name => 'direct_debit', description => $::locale->text('direct debit') }, + { name => 'duedate', description => $::locale->text('Due Date') }, + { name => 'employee_id', description => $::locale->text('Employee (database ID)') }, + { name => 'exchangerate', description => $::locale->text('Exchangerate') }, + { name => 'globalproject_id', description => $::locale->text('Document Project (database ID)') }, + { name => 'globalprojectnumber', description => $::locale->text('Document Project (number)') }, + { name => 'globalproject', description => $::locale->text('Document Project (description)') }, + { name => 'intnotes', description => $::locale->text('Internal Notes') }, + { name => 'invnumber', description => $::locale->text('Invoice Number') }, + { name => 'is_sepa_blocked', description => $::locale->text('Bank transfer via SEPA is blocked') }, + { name => 'notes', description => $::locale->text('Notes') }, + { name => 'orddate', description => $::locale->text('Order Date') }, + { name => 'ordnumber', description => $::locale->text('Order Number') }, + { name => 'payment_id', description => $::locale->text('Payment terms (database ID)') }, + { name => 'payment', description => $::locale->text('Payment terms (name)') }, + { name => 'quonumber', description => $::locale->text('Quotation Number') }, + { name => 'quodate', description => $::locale->text('Quotation Date') }, + { name => 'tax_point', description => $::locale->text('Tax point') }, + { name => 'taxincluded', description => $::locale->text('Tax Included') }, + { name => 'transaction_description', description => $::locale->text('Transaction description') }, + { name => 'transdate', description => $::locale->text('Invoice Date') }, + { name => 'vendor', description => $::locale->text('Vendor (name)') }, + { name => 'vendornumber', description => $::locale->text('Vendor Number') }, + { name => 'vendor_gln', description => $::locale->text('Vendor GLN') }, + { name => 'vendor_id', description => $::locale->text('Vendor (database ID)') }, + { name => 'verify_amount', description => $::locale->text('Amount (for verification)') . ' [2]' }, + { name => 'verify_netamount', description => $::locale->text('Net amount (for verification)') . ' [2]' }, + { name => 'apchart', description => $::locale->text('Payable account (account number)') }, + ); + + $self->add_displayable_columns($self->_transaction_column, + { name => 'datatype', description => $self->_transaction_column . ' [1]' }, + { name => 'amount', description => $::locale->text('Amount') }, + { name => 'chart_id', description => $::locale->text('Account number (database ID)') }, + { name => 'project_id', description => $::locale->text('Project (database ID)') }, + { name => 'project', description => $::locale->text('Project (description)') }, + { name => 'projectnumber', description => $::locale->text('Project (number)') }, + { name => 'taxkey', description => $::locale->text('Taxkey') }, + { name => 'accno', description => $::locale->text('Account number') }, + ); +} + +sub init_taxkeys_by { + my ($self) = @_; + + my $all_taxes = SL::DB::Manager::Tax->get_all; + return { map { $_->taxkey => $_->id } @{ $all_taxes } }; +} + + +sub init_charts_by { + my ($self) = @_; + + my $all_charts = SL::DB::Manager::Chart->get_all; + return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $all_charts } } ) } qw(id accno) }; +} + +sub check_objects { + my ($self) = @_; + + $self->controller->track_progress(phase => 'building data', progress => 0); + + my $i = 0; + my $num_data = scalar @{ $self->controller->data }; + my $invoice_entry; + + foreach my $entry (@{ $self->controller->data }) { + $self->controller->track_progress(progress => $i/$num_data * 100) if $i % 100 == 0; + + if ($entry->{raw_data}->{datatype} eq $self->_ap_column) { + $entry->{info_data}->{datatype} = $::locale->text($self->_ap_column); + $self->handle_invoice($entry); + $invoice_entry = $entry; + } elsif ($entry->{raw_data}->{datatype} eq $self->_transaction_column ) { + die "Cannot process transaction row without an invoice row" if !$invoice_entry; + $entry->{info_data}->{datatype} = $::locale->text($self->_transaction_column); + $self->handle_transaction($entry, $invoice_entry); + } else { + die "unknown datatype"; + }; + + } continue { + $i++; + } # finished data parsing + + $self->add_info_columns($self->_ap_column, { header => 'datatype', method => 'datatype' }); + $self->add_info_columns($self->_transaction_column, { header => 'datatype', method => 'datatype' }); + + $self->add_transactions_to_ap(); # go through all data entries again, adding payable entry to ap lines while calculating amount and netamount + + $self->check_verify_amounts(); + + foreach my $entry (@{ $self->controller->data }) { + next unless ($entry->{raw_data}->{datatype} eq $self->_ap_column); + unless ( $entry->{object}->validate_acc_trans ) { + push @{ $entry->{errors} }, $::locale->text('Error: ap transaction doesn\'t validate'); + }; + }; + + # add info columns that aren't directly part of the object to be imported + # but are always determined or should always be shown because they are mandatory + $self->add_info_columns($self->_ap_column, + { header => $::locale->text('Vendor'), method => 'vc_name' }, + { header => $::locale->text('Payable account'), method => 'apchart' }, + { header => $::locale->text('Amount'), method => 'amount' }, + { header => $::locale->text('Net amount'), method => 'netamount' }, + { header => $::locale->text('Tax zone'), method => 'taxzone' }); + + # Adding info_header this way only works, if the first invoice $self->controller->data->[0] + + # Todo: access via ->[0] ok? Better: search first order column and use this + $self->add_info_columns($self->_ap_column, { header => $::locale->text('Department'), method => 'department' }) if $self->controller->data->[0]->{info_data}->{department} or $self->controller->data->[0]->{raw_data}->{department}; + + $self->add_info_columns($self->_ap_column, { header => $::locale->text('Project Number'), method => 'globalprojectnumber' }) if $self->controller->data->[0]->{info_data}->{globalprojectnumber}; + + $self->add_columns($self->_ap_column, + map { "${_}_id" } grep { exists $self->controller->data->[0]->{raw_data}->{$_} } qw(payment department globalproject taxzone currency)); + $self->add_columns($self->_ap_column, 'globalproject_id') if exists $self->controller->data->[0]->{raw_data}->{globalprojectnumber}; + $self->add_columns($self->_ap_column, 'notes') if exists $self->controller->data->[0]->{raw_data}->{notes}; + + # Todo: access via ->[1] ok? Better: search first item column and use this + $self->add_info_columns($self->_transaction_column, { header => $::locale->text('Chart'), method => 'accno' }); + $self->add_columns($self->_transaction_column, 'amount'); + + $self->add_info_columns($self->_transaction_column, { header => $::locale->text('Project Number'), method => 'projectnumber' }) if $self->controller->data->[1]->{info_data}->{projectnumber}; + + # If requested to not save anything on errors, set all ap rows without error to an error + if ($self->controller->profile->get('dont_save_anything_on_errors')) { + my $any_errors = any { scalar @{ $_->{errors} } } @{ $self->controller->data }; + if ($any_errors) { + foreach my $entry (grep { ($_->{raw_data}->{datatype} eq $self->_ap_column) && !scalar @{ $_->{errors} } } @{ $self->controller->data }) { + push @{ $entry->{errors} }, $::locale->text('There are some errors in the file and it was requested to not save any datasets on errors.'); + } + } + } + + # If invoice has errors, add error for acc_trans items + # If acc_trans item has an error, add an error to the invoice item + my $ap_entry; + foreach my $entry (@{ $self->controller->data }) { + # Search first order + if ($entry->{raw_data}->{datatype} eq $self->_ap_column) { + $ap_entry = $entry; + } elsif ( defined $ap_entry + && $entry->{raw_data}->{datatype} eq $self->_transaction_column + && scalar @{ $ap_entry->{errors} } > 0 ) { + push @{ $entry->{errors} }, $::locale->text('Error: invalid ap row for this transaction'); + } elsif ( defined $ap_entry + && $entry->{raw_data}->{datatype} eq $self->_transaction_column + && scalar @{ $entry->{errors} } > 0 ) { + push @{ $ap_entry->{errors} }, $::locale->text('Error: invalid acc transactions for this ap row'); + } + } +} + +sub handle_invoice { + + my ($self, $entry) = @_; + + my $object = $entry->{object}; + + $object->transactions( [] ); # initialise transactions for ap object so methods work on unsaved transactions + + my $vc_obj; + if (any { $entry->{raw_data}->{$_} } qw(vendor vendornumber vendor_gln vendor_id)) { + $self->check_vc($entry, 'vendor_id'); + $vc_obj = SL::DB::Vendor->new(id => $object->vendor_id)->load if $object->vendor_id; + } else { + push @{ $entry->{errors} }, $::locale->text('Error: Vendor missing'); + } + + if (!$entry->{raw_data}->{invnumber}) { + push @{ $entry->{errors} }, $::locale->text('Error: Invoice Number missing'); + } + + $self->check_apchart($entry); # checks for payable account + $self->check_payment($entry); # currency default from vendor used below + $self->check_department($entry); + $self->check_taxincluded($entry); + $self->check_project($entry, global => 1); + $self->check_taxzone($entry); # taxzone default from customer used below + $self->check_currency($entry); # currency default from customer used below + $self->handle_employee($entry); + + if ($vc_obj ) { + # copy defaults from customer if not specified in import file + foreach (qw(payment_id language_id taxzone_id currency_id)) { + $object->$_($vc_obj->$_) unless $object->$_; + } + } +} + +sub check_taxkey { + my ($self, $entry, $invoice_entry, $chart) = @_; + + die "check_taxkey needs chart object as an argument" unless ref($chart) eq 'SL::DB::Chart'; + # problem: taxkey is not unique in table tax, normally one of those entries is chosen directly from a dropdown + # so we check if the chart has an active taxkey, and if it matches the taxkey from the import, use the active taxkey + # if the chart doesn't have an active taxkey, use the first entry from Tax that matches the taxkey + + my $object = $entry->{object}; + my $invoice_object = $invoice_entry->{object}; + + unless ( defined $entry->{raw_data}->{taxkey} ) { + push @{ $entry->{errors} }, $::locale->text('Error: taxkey missing'); # don't just assume 0, force taxkey in import + return 0; + }; + + my $tax = $chart->get_active_taxkey($invoice_object->deliverydate // $invoice_object->transdate // DateTime->today_local)->tax; + if ( $entry->{raw_data}->{taxkey} != $tax->taxkey ) { + # assume there is only one tax entry with that taxkey, can't guess + $tax = SL::DB::Manager::Tax->get_first( where => [ taxkey => $entry->{raw_data}->{taxkey} ]); + }; + + unless ( $tax ) { + push @{ $entry->{errors} }, $::locale->text('Error: invalid taxkey'); + return 0; + }; + + $object->taxkey($tax->taxkey); + $object->tax_id($tax->id); + return 1; +}; + +sub handle_transaction { + my ($self, $entry, $invoice_entry) = @_; + + # Prepare acc_trans data. amount is dealt with in add_transactions_to_ap + + my $object = $entry->{object}; + + $self->check_project($entry, global => 0); + if ( $self->check_chart($entry) ) { + my $chart_obj = SL::DB::Manager::Chart->find_by(id => $object->chart_id); + + unless ( $chart_obj->link =~ /AP_amount/ ) { + push @{ $entry->{errors} }, $::locale->text('Error: chart isn\'t an ap_amount chart'); + return 0; + }; + + if ( $self->check_taxkey($entry, $invoice_entry, $chart_obj) ) { + # do nothing, taxkey was assigned, just continue + } else { + # missing taxkey, don't do anything + return 0; + }; + } else { + return 0; + }; + + # check whether taxkey and automatic taxkey match + # die sprintf("taxkeys don't match: %s not equal default taxkey for chart %s: %s", $object->taxkey, $chart_obj->accno, $active_tax_for_chart->tax->taxkey) unless $object->taxkey == $active_tax_for_chart->tax->taxkey; + + die "no taxkey for transaction object" unless $object->taxkey or $object->taxkey == 0; + +} + +sub check_chart { + my ($self, $entry) = @_; + + my $object = $entry->{object}; + + if (any { $entry->{raw_data}->{$_} } qw(accno chart_id)) { + + # Check whether or not chart ID is valid. + if ($object->chart_id && !$self->charts_by->{id}->{ $object->chart_id }) { + push @{ $entry->{errors} }, $::locale->text('Error: invalid chart_id'); + return 0; + } + + # Map number to ID if given. + if (!$object->chart_id && $entry->{raw_data}->{accno}) { + my $chart = $self->charts_by->{accno}->{ $entry->{raw_data}->{accno} }; + if (!$chart) { + push @{ $entry->{errors} }, $::locale->text('Error: invalid chart (accno)'); + return 0; + } + + $object->chart_id($chart->id); + } + + # Map description to ID if given. + if (!$object->chart_id && $entry->{raw_data}->{description}) { + my $chart = $self->charts_by->{description}->{ $entry->{raw_data}->{description} }; + if (!$chart) { + push @{ $entry->{errors} }, $::locale->text('Error: invalid chart'); + return 0; + } + + $object->chart_id($chart->id); + } + + if ($object->chart_id) { + # add account number to preview + $entry->{info_data}->{accno} = $self->charts_by->{id}->{ $object->chart_id }->accno; + } else { + push @{ $entry->{errors} }, $::locale->text('Error: chart not found'); + return 0; + } + } else { + push @{ $entry->{errors} }, $::locale->text('Error: chart missing'); + return 0; + } + + return 1; +} + +sub check_apchart { + my ($self, $entry) = @_; + + my $chart; + + if ( $entry->{raw_data}->{apchart} ) { + my $apchart = $entry->{raw_data}->{apchart}; + $chart = SL::DB::Manager::Chart->find_by(accno => $apchart); + unless ($chart) { + push @{ $entry->{errors} }, $::locale->text("Error: can't find ap chart with accno #1", $apchart); + return 0; + } + } elsif ( $::instance_conf->get_ap_chart_id ) { + $chart = SL::DB::Manager::Chart->find_by(id => $::instance_conf->get_ap_chart_id); + } else { + push @{ $entry->{errors} }, $::locale->text("Error: neither apchart passed, no default payable chart configured"); + return 0; + }; + + unless ($chart->link eq 'AP') { + push @{ $entry->{errors} }, $::locale->text('Error: apchart isn\'t an AP chart'); + return 0; + }; + + $entry->{info_data}->{apchart} = $chart->accno; + $entry->{object}->{apchart} = $chart; + return 1; +}; + +sub check_taxincluded { + my ($self, $entry) = @_; + + my $object = $entry->{object}; + + if ( $entry->{raw_data}->{taxincluded} ) { + if ( $entry->{raw_data}->{taxincluded} eq 'f' or $entry->{raw_data}->{taxincluded} eq '0' ) { + $object->taxincluded('0'); + } elsif ( $entry->{raw_data}->{taxincluded} eq 't' or $entry->{raw_data}->{taxincluded} eq '1' ) { + $object->taxincluded('1'); + } else { + push @{ $entry->{errors} }, $::locale->text('Error: taxincluded has to be t or f'); + return 0; + }; + } else { + push @{ $entry->{errors} }, $::locale->text('Error: taxincluded wasn\'t set'); + return 0; + }; + return 1; +}; + +sub check_verify_amounts { + my ($self) = @_; + + # If amounts are given, show calculated amounts as info and given amounts (verify_xxx). + # And throw an error if the differences are too big. + my @to_verify = ( { column => 'amount', + raw_column => 'verify_amount', + info_header => 'Calc. Amount', + info_method => 'calc_amount', + err_msg => $::locale->text('Amounts differ too much'), + }, + { column => 'netamount', + raw_column => 'verify_netamount', + info_header => 'Calc. Net amount', + info_method => 'calc_netamount', + err_msg => $::locale->text('Net amounts differ too much'), + } ); + + foreach my $tv (@to_verify) { + if (exists $self->controller->data->[0]->{raw_data}->{ $tv->{raw_column} }) { + $self->add_raw_data_columns($self->_ap_column, $tv->{raw_column}); + $self->add_info_columns($self->_ap_column, + { header => $::locale->text($tv->{info_header}), method => $tv->{info_method} }); + } + + # check differences + foreach my $entry (@{ $self->controller->data }) { + if ($entry->{raw_data}->{datatype} eq $self->_ap_column) { + next if !$entry->{raw_data}->{ $tv->{raw_column} }; + my $parsed_value = $::form->parse_amount(\%::myconfig, $entry->{raw_data}->{ $tv->{raw_column} }); + # round $abs_diff, otherwise it might trigger for 0.020000000000021 + my $abs_diff = $::form->round_amount(abs($entry->{object}->${ \$tv->{column} } - $parsed_value),2); + if ( $abs_diff > $self->settings->{'max_amount_diff'}) { + push @{ $entry->{errors} }, $::locale->text($tv->{err_msg}); + } + } + } + } +}; + +sub add_transactions_to_ap { + my ($self) = @_; + + # go through all verified ap and acc_trans rows in import, adding acc_trans objects to ap objects + + my $ap_entry; # the current ap row + + foreach my $entry (@{ $self->controller->data }) { + # when we reach an ap_column for the first time, don't do anything, just store in $ap_entry + # when we reach an ap_column for the second time, save it + if ($entry->{raw_data}->{datatype} eq $self->_ap_column) { + if ( $ap_entry && $ap_entry->{object} ) { # won't trigger the first time, finishes the last object + if ( $ap_entry->{object}->{apchart} && $ap_entry->{object}->{apchart}->isa('SL::DB::Chart') ) { + $ap_entry->{object}->recalculate_amounts; # determine and set amount and netamount for ap + $ap_entry->{object}->create_ap_row(chart => $ap_entry->{object}->{apchart}); + $ap_entry->{info_data}->{calc_amount} = $ap_entry->{object}->amount_as_number; + $ap_entry->{info_data}->{calc_netamount} = $ap_entry->{object}->netamount_as_number; + } else { + push @{ $ap_entry->{errors} }, $::locale->text("The payable chart isn't a valid chart."); + }; + }; + $ap_entry = $entry; # remember as last ap_entry + + } elsif ( defined $ap_entry && $entry->{raw_data}->{datatype} eq $self->_transaction_column ) { + push @{ $entry->{errors} }, $::locale->text('no tax_id in acc_trans') if !defined $entry->{object}->tax_id; + next if @{ $entry->{errors} }; + + my $acc_trans_objects = $ap_entry->{object}->add_ap_amount_row( + amount => $entry->{object}->amount, + chart => SL::DB::Manager::Chart->find_by(id => $entry->{object}->chart_id), # add_ap_amount takes chart obj. as argument + tax_id => $entry->{object}->tax_id, + project_id => $entry->{object}->project_id, + debug => 0, + ); + + } else { + die "This should never happen\n"; + }; + } + + # finish the final object + if ( $ap_entry->{object} ) { + if ( $ap_entry->{object}->{apchart} && $ap_entry->{object}->{apchart}->isa('SL::DB::Chart') ) { + $ap_entry->{object}->recalculate_amounts; + $ap_entry->{info_data}->{calc_amount} = $ap_entry->{object}->amount_as_number; + $ap_entry->{info_data}->{calc_netamount} = $ap_entry->{object}->netamount_as_number; + + $ap_entry->{object}->create_ap_row(chart => $ap_entry->{object}->{apchart}); + } else { + push @{ $ap_entry->{errors} }, $::locale->text("The payable chart isn't a valid chart."); + return 0; + }; + } else { + die "There was no final ap_entry object"; + }; +} + +sub save_objects { + my ($self, %params) = @_; + + # save all the Invoice objects + my $objects_to_save; + foreach my $entry (@{ $self->controller->data }) { + # only push the invoice objects that don't have an error + next if $entry->{raw_data}->{datatype} ne $self->_ap_column; + next if @{ $entry->{errors} }; + + die unless $entry->{object}->validate_acc_trans; + + push @{ $objects_to_save }, $entry; + } + + $self->SUPER::save_objects(data => $objects_to_save); +} + +sub _ap_column { + $_[0]->settings->{'ap_column'} +} + +sub _transaction_column { + $_[0]->settings->{'transaction_column'} +} + +1; diff --git a/SL/Controller/CsvImport/ARTransaction.pm b/SL/Controller/CsvImport/ARTransaction.pm index 36760fe38..d5a5905a9 100644 --- a/SL/Controller/CsvImport/ARTransaction.pm +++ b/SL/Controller/CsvImport/ARTransaction.pm @@ -181,10 +181,7 @@ sub check_objects { $self->add_transactions_to_ar(); # go through all data entries again, adding receivable entry to ar lines while calculating amount and netamount - foreach my $entry (@{ $self->controller->data }) { - next unless ($entry->{raw_data}->{datatype} eq $self->_ar_column); - $self->check_verify_amounts($entry->{object}); - }; + $self->check_verify_amounts(); foreach my $entry (@{ $self->controller->data }) { next unless ($entry->{raw_data}->{datatype} eq $self->_ar_column); @@ -501,11 +498,6 @@ sub check_verify_amounts { # check differences foreach my $entry (@{ $self->controller->data }) { - if ( @{ $entry->{errors} } ) { - push @{ $entry->{errors} }, $::locale->text($tv->{err_msg}); - return 0; - }; - if ($entry->{raw_data}->{datatype} eq $self->_ar_column) { next if !$entry->{raw_data}->{ $tv->{raw_column} }; my $parsed_value = $::form->parse_amount(\%::myconfig, $entry->{raw_data}->{ $tv->{raw_column} }); @@ -534,10 +526,10 @@ sub add_transactions_to_ar { if ( $ar_entry->{object}->{archart} && $ar_entry->{object}->{archart}->isa('SL::DB::Chart') ) { $ar_entry->{object}->recalculate_amounts; # determine and set amount and netamount for ar $ar_entry->{object}->create_ar_row(chart => $ar_entry->{object}->{archart}); - $ar_entry->{info_data}->{amount} = $ar_entry->{object}->amount; - $ar_entry->{info_data}->{netamount} = $ar_entry->{object}->netamount; + $ar_entry->{info_data}->{calc_amount} = $ar_entry->{object}->amount_as_number; + $ar_entry->{info_data}->{calc_netamount} = $ar_entry->{object}->netamount_as_number; } else { - push @{ $entry->{errors} }, $::locale->text("ar_chart isn't a valid chart"); + push @{ $ar_entry->{errors} }, $::locale->text("The receivables chart isn't a valid chart."); }; }; $ar_entry = $entry; # remember as last ar_entry @@ -563,8 +555,8 @@ sub add_transactions_to_ar { if ( $ar_entry->{object} ) { if ( $ar_entry->{object}->{archart} && $ar_entry->{object}->{archart}->isa('SL::DB::Chart') ) { $ar_entry->{object}->recalculate_amounts; - $ar_entry->{info_data}->{amount} = $ar_entry->{object}->amount; - $ar_entry->{info_data}->{netamount} = $ar_entry->{object}->netamount; + $ar_entry->{info_data}->{calc_amount} = $ar_entry->{object}->amount_as_number; + $ar_entry->{info_data}->{calc_netamount} = $ar_entry->{object}->netamount_as_number; $ar_entry->{object}->create_ar_row(chart => $ar_entry->{object}->{archart}); } else { diff --git a/SL/Controller/CsvImport/BankTransaction.pm b/SL/Controller/CsvImport/BankTransaction.pm index 77063738c..1d447a34a 100644 --- a/SL/Controller/CsvImport/BankTransaction.pm +++ b/SL/Controller/CsvImport/BankTransaction.pm @@ -50,6 +50,7 @@ sub check_objects { $self->check_currency($entry, take_default => 1); $self->join_purposes($entry); $self->join_remote_names($entry); + $self->extract_end_to_end_id($entry); $self->check_existing($entry) unless @{ $entry->{errors} }; } continue { $i++; @@ -57,6 +58,7 @@ sub check_objects { $self->add_info_columns({ header => $::locale->text('Bank account'), method => 'local_bank_name' }); $self->add_raw_data_columns("currency", "currency_id") if grep { /^currency(?:_id)?$/ } @{ $self->csv->header }; + $self->add_info_columns({ header => $::locale->text('End to end ID'), method => 'end_to_end_id' }); } sub check_existing { @@ -76,7 +78,16 @@ sub check_existing { # * amount # * local_bank_account_id (case flatrate bank charges for two accounts in one bank: same purpose, transdate, remote_account_number(empty), amount. Just different local_bank_account_id) my $num; - if ( $num = SL::DB::Manager::BankTransaction->get_all_count(query =>[ remote_account_number => $object->remote_account_number, transdate => $object->transdate, purpose => $object->purpose, amount => $object->amount, local_bank_account_id => $object->local_bank_account_id] ) ) { + + my @conditions; + + if ($object->end_to_end_id && $::instance_conf->get_check_bt_duplicates_endtoend) { + push @conditions, ( end_to_end_id => $object->end_to_end_id ); + } else { + push @conditions, ( purpose => $object->purpose ); + } + + if ( $num = SL::DB::Manager::BankTransaction->get_all_count(query =>[ remote_account_number => $object->remote_account_number, transdate => $object->transdate, amount => $object->amount, local_bank_account_id => $object->local_bank_account_id, @conditions] ) ) { push(@{$entry->{errors}}, $::locale->text('Skipping due to existing bank transaction in database')); }; } else { @@ -111,7 +122,8 @@ sub _displayable_columns { { name => 'purpose10', description => $::locale->text('Purpose (if field names purpose, purpose1, purpose2 ... exist they will all combined into the field "purpose")') }, { name => 'purpose11', description => $::locale->text('Purpose (if field names purpose, purpose1, purpose2 ... exist they will all combined into the field "purpose")') }, { name => 'purpose12', description => $::locale->text('Purpose (if field names purpose, purpose1, purpose2 ... exist they will all combined into the field "purpose")') }, - { name => 'purpose13', description => $::locale->text('Purpose (if field names purpose, purpose1, purpose2 ... exist they will all combined into the field "purpose")') } + { name => 'purpose13', description => $::locale->text('Purpose (if field names purpose, purpose1, purpose2 ... exist they will all combined into the field "purpose")') }, + { name => 'qr_reference', description => $::locale->text('QR reference') } ); } @@ -167,6 +179,13 @@ sub check_bank_account { $object->local_bank_account_id($bank_account->id); $entry->{info_data}->{local_bank_name} = $bank_account->name; } + + # Check if local bank account is marked for bank import + if ($object->local_bank_account_id && !$self->bank_accounts_by->{id}->{ $object->local_bank_account_id }->use_with_bank_import) { + push @{ $entry->{errors} }, $::locale->text('Error: local bank account is not marked for bank import, check settings under System -> Bank Accounts'); + return 0; + } + return $object->local_bank_account_id ? 1 : 0; } @@ -195,6 +214,19 @@ sub join_remote_names { $object->remote_name($remote_name); } +sub extract_end_to_end_id { + my ($self, $entry) = @_; + + my $object = $entry->{object}; + + return if $object->purpose !~ m{\b(?:end\W?to\W?end:|eref\+) *([^ ]+)}i; + + my $id = $1; + + $object->end_to_end_id($id) if $id !~ m{notprovided}i; + $entry->{info_data}->{end_to_end_id} = $object->end_to_end_id; +} + sub check_auth { $::auth->assert('config') if ! $::auth->assert('bank_transaction',1); } diff --git a/SL/Controller/CsvImport/Base.pm b/SL/Controller/CsvImport/Base.pm index 1aaec79c9..3df233267 100644 --- a/SL/Controller/CsvImport/Base.pm +++ b/SL/Controller/CsvImport/Base.pm @@ -64,6 +64,10 @@ sub run { $self->controller->info_headers({ used => { }, headers => [ ] }); my $objects = $self->csv->get_objects; + if ($self->csv->errors) { + $self->controller->errors([ $self->csv->errors ]) ; + return; + } $self->controller->track_progress(progress => 70); @@ -530,6 +534,33 @@ sub save_objects { return unless $data->[0]; return unless $data->[0]{object}; + # If we store into tables which get numbers from the TransNumberGenerator + # we have to lock all tables referenced by the storage table (or by + # tables stored alongside with the storage table) that are handled by + # the TransNumberGenerator, too. + # Otherwise we can run into a deadlock if someone saves a document via + # the user interface. The exact behavoir depends on timing. + # E.g. we are importing orders and a user want to + # book an invoice: + # web: locks ar (via before-save hook and TNG (or SL::TransNumber)) + # importer: locks oe (via before-save hook and TNG) (*) + # importer: locks defaults (via before-save hook and TNG) + # web: wants to lock defaults (via before-save hook and TNG (or SL::TransNumber)) -> is waiting + # importer: wants to save oe and wants to lock referenced tables (here ar) -> is waiting + # --> deadlock + # + # (*) if the importer locks ar here, too, everything is fine, because it will wait here + # before locking the defaults table. + # + # List of referenced tables: + # (Locking is done in the transaction below) + my %referenced_tables_by_type = ( + orders => [qw(ar customer vendor)], + delivery_orders => [qw(customer vendor) ], + ar_transactions => [qw(customer) ], + ap_transactions => [qw(vendor) ], + ); + $self->controller->track_progress(phase => 'saving data', progress => 0); # scale from 45..95%; my $last_index = $#$data; @@ -538,6 +569,11 @@ sub save_objects { for my $chunk (0 .. $last_index / $chunk_size) { $self->controller->track_progress(progress => ($chunk_size * $chunk)/scalar(@$data) * 100); # scale from 45..95%; SL::DB->client->with_transaction(sub { + + foreach my $refs (@{ $referenced_tables_by_type{$self->controller->{type}} || [] }) { + SL::DB->client->dbh->do("LOCK " . $refs) || die SL::DB->client->dbh->errstr; + } + foreach my $entry_index ($chunk_size * $chunk .. min( $last_index, $chunk_size * ($chunk + 1) - 1 )) { my $entry = $data->[$entry_index]; diff --git a/SL/Controller/CsvImport/BaseMulti.pm b/SL/Controller/CsvImport/BaseMulti.pm index 9ddb86bea..94cfa8431 100644 --- a/SL/Controller/CsvImport/BaseMulti.pm +++ b/SL/Controller/CsvImport/BaseMulti.pm @@ -33,8 +33,8 @@ sub run { $self->controller->track_progress(progress => 10); - my $old_numberformat = $::myconfig{numberformat}; - $::myconfig{numberformat} = $self->controller->profile->get('numberformat'); + local $::myconfig{numberformat} = $self->controller->profile->get('numberformat'); + local $::myconfig{dateformat} = $self->controller->profile->get('dateformat'); $self->csv->parse; @@ -71,6 +71,10 @@ sub run { $self->controller->info_headers($info_headers); my $objects = $self->csv->get_objects; + if ($self->csv->errors) { + $self->controller->errors([ $self->csv->errors ]) ; + return; + } $self->controller->track_progress(progress => 70); @@ -90,8 +94,6 @@ sub run { $self->fix_field_lengths; $self->controller->track_progress(progress => 100); - - $::myconfig{numberformat} = $old_numberformat; } sub init_manager_class { diff --git a/SL/Controller/CsvImport/CustomerVendor.pm b/SL/Controller/CsvImport/CustomerVendor.pm index 2f8b15c06..946b392a0 100644 --- a/SL/Controller/CsvImport/CustomerVendor.pm +++ b/SL/Controller/CsvImport/CustomerVendor.pm @@ -82,6 +82,7 @@ sub check_objects { $self->check_taxzone($entry, take_default => 1); $self->check_currency($entry, take_default => 1); $self->check_salesman($entry); + $self->check_pricegroup($entry) if 'customer' eq $self->table; next if @{ $entry->{errors} }; @@ -116,7 +117,7 @@ sub check_objects { $i++; } - $self->add_columns(map { "${_}_id" } grep { exists $self->controller->data->[0]->{raw_data}->{$_} } qw(language business payment delivery_term taxzone)); + $self->add_columns(map { "${_}_id" } grep { exists $self->controller->data->[0]->{raw_data}->{$_} } qw(language business payment delivery_term taxzone pricegroup)); $self->add_cvar_raw_data_columns; } @@ -294,6 +295,7 @@ sub setup_displayable_columns { { name => 'homepage', description => $::locale->text('Homepage') }, { name => 'iban', description => $::locale->text('IBAN') }, { name => 'pricegroup_id', description => $::locale->text('Price group (database ID)') }, + { name => 'pricegroup', description => $::locale->text('Price group (name)') }, { name => 'language_id', description => $::locale->text('Language (database ID)') }, { name => 'language', description => $::locale->text('Language (name)') }, { name => 'name', description => $::locale->text('Name') }, diff --git a/SL/Controller/CsvImport/DeliveryOrder.pm b/SL/Controller/CsvImport/DeliveryOrder.pm index 2e5a9e9ab..209324997 100644 --- a/SL/Controller/CsvImport/DeliveryOrder.pm +++ b/SL/Controller/CsvImport/DeliveryOrder.pm @@ -16,7 +16,6 @@ use SL::DB::Part; use SL::DB::PaymentTerm; use SL::DB::Contact; use SL::DB::PriceFactor; -use SL::DB::Pricegroup; use SL::DB::Shipto; use SL::DB::Unit; use SL::DB::Inventory; @@ -34,7 +33,7 @@ use Rose::Object::MakeMethods::Generic ( 'scalar --get_set_init' => [ qw(settings languages_by all_parts parts_by part_counts_by contacts_by ct_shiptos_by - price_factors_by pricegroups_by units_by + price_factors_by units_by warehouses_by bins_by transfer_types_by) ], ); @@ -219,7 +218,7 @@ sub setup_displayable_columns { { name => 'language', description => $::locale->text('Language (name)') }, { name => 'language_id', description => $::locale->text('Language (database ID)') }, { name => 'notes', description => $::locale->text('Notes') }, - { name => 'order_type', description => $::locale->text('Delivery Order Type') }, + { name => 'record_type', description => $::locale->text('Delivery Order Type') }, { name => 'ordnumber', description => $::locale->text('Order Number') }, { name => 'payment', description => $::locale->text('Payment terms (name)') }, { name => 'payment_id', description => $::locale->text('Payment terms (database ID)') }, @@ -347,13 +346,6 @@ sub init_price_factors_by { return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $all_price_factors } } ) } qw(id description) }; } -sub init_pricegroups_by { - my ($self) = @_; - - my $all_pricegroups = SL::DB::Manager::Pricegroup->get_all; - return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $all_pricegroups } } ) } qw(id pricegroup) }; -} - sub init_units_by { my ($self) = @_; @@ -672,7 +664,7 @@ sub handle_stock { parts_id => $item_obj->parts_id, warehouse_id => $object->warehouse_id, bin_id => $object->bin_id, - trans_type_id => $trans_type_id, + PURCHASE_DELIVERY_ORDER_TYPE() => $trans_type_id, qty => $qty, chargenumber => $object->chargenumber, employee_id => $order_obj->employee_id, @@ -689,11 +681,11 @@ sub handle_stock { sub handle_type { my ($self, $entry) = @_; - if (!exists $entry->{raw_data}->{order_type}) { + if (!exists $entry->{raw_data}->{record_type}) { # if no type is present - set to sales delivery order or purchase delivery # order depending on is_sales or customer/vendor - $entry->{object}->order_type( + $entry->{object}->record_type( $entry->{object}->customer_id ? SALES_DELIVERY_ORDER_TYPE : $entry->{object}->vendor_id ? PURCHASE_DELIVERY_ORDER_TYPE : $entry->{raw_data}->{is_sales} ? SALES_DELIVERY_ORDER_TYPE : @@ -1047,31 +1039,6 @@ sub check_price_factor { return 1; } -sub check_pricegroup { - my ($self, $entry) = @_; - - my $object = $entry->{object}; - - # Check whether or not pricegroup ID is valid. - if ($object->pricegroup_id && !$self->pricegroups_by->{id}->{ $object->pricegroup_id }) { - push @{ $entry->{errors} }, $::locale->text('Error: Invalid price group'); - return 0; - } - - # Map pricegroup to ID if given. - if (!$object->pricegroup_id && $entry->{raw_data}->{pricegroup}) { - my $pricegroup = $self->pricegroups_by->{pricegroup}->{ $entry->{raw_data}->{pricegroup} }; - if (!$pricegroup) { - push @{ $entry->{errors} }, $::locale->text('Error: Invalid price group'); - return 0; - } - - $object->pricegroup_id($pricegroup->id); - } - - return 1; -} - sub check_warehouse { my ($self, $entry) = @_; diff --git a/SL/Controller/CsvImport/Helper/Consistency.pm b/SL/Controller/CsvImport/Helper/Consistency.pm index 5538a738c..33eb42983 100644 --- a/SL/Controller/CsvImport/Helper/Consistency.pm +++ b/SL/Controller/CsvImport/Helper/Consistency.pm @@ -6,13 +6,14 @@ use Data::Dumper; use SL::DB::Default; use SL::DB::Currency; use SL::DB::TaxZone; +use SL::DB::Pricegroup; use SL::DB::Project; use SL::DB::Department; use SL::Helper::Csv::Error; use parent qw(Exporter); -our @EXPORT = qw(check_currency check_taxzone check_project check_department check_customer_vendor handle_salesman handle_employee); +our @EXPORT = qw(check_currency check_taxzone check_pricegroup check_project check_department check_customer_vendor handle_salesman handle_employee); # # public functions @@ -113,6 +114,31 @@ sub check_taxzone { return 1; } +sub check_pricegroup { + my ($self, $entry) = @_; + + my $object = $entry->{object}; + + # Check whether or not pricegroup ID is valid. + if ($object->pricegroup_id && !_pricegroups_by($self)->{id}->{ $object->pricegroup_id }) { + push @{ $entry->{errors} }, $::locale->text('Error: Invalid price group'); + return 0; + } + + # Map pricegroup to ID if given. + if (!$object->pricegroup_id && $entry->{raw_data}->{pricegroup}) { + my $pricegroup = _pricegroups_by($self)->{pricegroup}->{ $entry->{raw_data}->{pricegroup} }; + if (!$pricegroup) { + push @{ $entry->{errors} }, $::locale->text('Error: Invalid price group'); + return 0; + } + + $object->pricegroup_id($pricegroup->id); + } + + return 1; +} + sub check_project { my ($self, $entry, %params) = @_; @@ -266,6 +292,13 @@ sub _departments_by { return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $all_departments } } ) } qw(id description) }; } +sub _pricegroups_by { + my ($self) = @_; + + my $all_pricegroups = SL::DB::Manager::Pricegroup->get_all; + return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $all_pricegroups } } ) } qw(id pricegroup) }; +} + sub _projects_by { my ($self) = @_; diff --git a/SL/Controller/CsvImport/Order.pm b/SL/Controller/CsvImport/Order.pm index 503b4d185..b8adc456e 100644 --- a/SL/Controller/CsvImport/Order.pm +++ b/SL/Controller/CsvImport/Order.pm @@ -14,7 +14,6 @@ use SL::DB::PaymentTerm; use SL::DB::Contact; use SL::DB::Department; use SL::DB::PriceFactor; -use SL::DB::Pricegroup; use SL::DB::Project; use SL::DB::Shipto; use SL::DB::TaxZone; @@ -26,7 +25,7 @@ use parent qw(SL::Controller::CsvImport::BaseMulti); use Rose::Object::MakeMethods::Generic ( - 'scalar --get_set_init' => [ qw(settings languages_by all_parts parts_by part_counts_by contacts_by ct_shiptos_by price_factors_by pricegroups_by units_by) ], + 'scalar --get_set_init' => [ qw(settings languages_by all_parts parts_by part_counts_by contacts_by ct_shiptos_by price_factors_by units_by) ], ); @@ -250,13 +249,6 @@ sub init_price_factors_by { return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $all_price_factors } } ) } qw(id description) }; } -sub init_pricegroups_by { - my ($self) = @_; - - my $all_pricegroups = SL::DB::Manager::Pricegroup->get_all; - return { map { my $col = $_; ( $col => { map { ( $_->$col => $_ ) } @{ $all_pricegroups } } ) } qw(id pricegroup) }; -} - sub init_units_by { my ($self) = @_; @@ -648,31 +640,6 @@ sub check_price_factor { return 1; } -sub check_pricegroup { - my ($self, $entry) = @_; - - my $object = $entry->{object}; - - # Check whether or not pricegroup ID is valid. - if ($object->pricegroup_id && !$self->pricegroups_by->{id}->{ $object->pricegroup_id }) { - push @{ $entry->{errors} }, $::locale->text('Error: Invalid price group'); - return 0; - } - - # Map pricegroup to ID if given. - if (!$object->pricegroup_id && $entry->{raw_data}->{pricegroup}) { - my $pricegroup = $self->pricegroups_by->{pricegroup}->{ $entry->{raw_data}->{pricegroup} }; - if (!$pricegroup) { - push @{ $entry->{errors} }, $::locale->text('Error: Invalid price group'); - return 0; - } - - $object->pricegroup_id($pricegroup->id); - } - - return 1; -} - sub add_items_to_order { my ($self) = @_; diff --git a/SL/Controller/CustomDataExport.pm b/SL/Controller/CustomDataExport.pm index 7d5eddee9..f42c8b892 100644 --- a/SL/Controller/CustomDataExport.pm +++ b/SL/Controller/CustomDataExport.pm @@ -12,6 +12,7 @@ use POSIX qw(strftime); use Text::CSV_XS; use SL::DB::CustomDataExportQuery; +use SL::Controller::Helper::ReportGenerator; use SL::Locale::String qw(t8); use Rose::Object::MakeMethods::Generic @@ -30,27 +31,71 @@ __PACKAGE__->run_before('setup_javascripts'); sub action_list { my ($self) = @_; - $self->render('custom_data_export/list', title => $::locale->text('Execute a custom data export query')); + $self->render('custom_data_export/list', title => $::locale->text('Execute a custom report query')); } sub action_export { my ($self) = @_; - if (!$::form->{format}) { + if (!$::form->{parameters_set}) { $self->setup_export_action_bar; - return $self->render('custom_data_export/export', title => t8("Execute custom data export '#1'", $self->query->name)); + return $self->render('custom_data_export/export', title => t8("Execute custom report '#1'", $self->query->name)); } $self->execute_query; if (scalar(@{ $self->rows // [] }) == 1) { $self->setup_empty_result_set_action_bar; - return $self->render('custom_data_export/empty_result_set', title => t8("Execute custom data export '#1'", $self->query->name)); + return $self->render('custom_data_export/empty_result_set', title => t8("Execute custom report '#1'", $self->query->name)); } + my $report = SL::ReportGenerator->new(\%::myconfig, $::form); - my $method = "export_as_" . $::form->{format}; - $self->$method; + my $report_name = $self->query->name; + $report_name =~ s{[^[:word:]]+}{_}ig; + $report_name .= strftime('_%Y-%m-%d_%H-%M-%S', localtime()); + + $report->set_options( + std_column_visibility => 1, + controller_class => 'CustomDataExport', + output_format => 'HTML', + top_info_text => $self->query->name, + title => $self->query->name, + allow_pdf_export => 1, + allow_csv_export => 1, + allow_chart_export => 1, + attachment_basename => $report_name, + ); + + my %column_defs; + foreach my $key (@{ $self->rows->[0] }) { + $column_defs{$key} = { text => $key, sub => sub { $_[0]->{$key} } }; + } + + $report->set_columns(%column_defs); + $report->set_column_order(@{ $self->rows->[0] }); + + $report->set_export_options(qw(export id parameters_set parameters)); + $report->set_options_from_form; + + # Setup data objects (which in this case is an array of hashes). + my @objects; + foreach my $set_idx (1..$#{ $self->rows }) { + my %row_set; + foreach my $key_idx (0..$#{ $self->rows->[0] }) { + my $key = $self->rows->[0]->[$key_idx]; + my $value = $self->rows->[$set_idx]->[$key_idx]; + $row_set{$key} = $value; + } + push @objects, \%row_set; + } + + $self->report_generator_list_objects(report => $report, + objects => \@objects, + options => { + action_bar_additional_submit_values => { id => $::form->{id}, }, + }, + ); } # @@ -59,6 +104,7 @@ sub action_export { sub check_auth { my ($self) = @_; + $::auth->assert('custom_data_report'); $::auth->assert($self->query->access_right) if $self->query->access_right; } @@ -94,7 +140,7 @@ sub setup_export_action_bar { for my $bar ($::request->layout->get('actionbar')) { $bar->add( action => [ - t8('Export'), + t8('Execute'), submit => [ '#form', { action => 'CustomDataExport/export' } ], checks => [ 'kivi.validate_form' ], accesskey => 'enter', @@ -170,32 +216,4 @@ sub execute_query { ]); } -sub export_as_csv { - my ($self) = @_; - - my $csv = Text::CSV_XS->new({ - binary => 1, - sep_char => ';', - eol => "\n", - }); - - my ($file_handle, $file_name) = File::Temp::tempfile; - - binmode $file_handle, ":encoding(utf8)"; - - $csv->print($file_handle, $_) for @{ $self->rows }; - - $file_handle->close; - - my $report_name = $self->query->name; - $report_name =~ s{[^[:word:]]+}{_}ig; - $report_name .= strftime('_%Y-%m-%d_%H-%M-%S.csv', localtime()); - - $self->send_file( - $file_name, - content_type => 'text/csv', - name => $report_name, - ); -} - 1; diff --git a/SL/Controller/CustomDataExportDesigner.pm b/SL/Controller/CustomDataExportDesigner.pm index 17266323b..53045485a 100644 --- a/SL/Controller/CustomDataExportDesigner.pm +++ b/SL/Controller/CustomDataExportDesigner.pm @@ -27,13 +27,13 @@ sub action_list { my ($self) = @_; $self->setup_list_action_bar; - $self->render('custom_data_export_designer/list', title => $::locale->text('Design custom data export queries')); + $self->render('custom_data_export_designer/list', title => $::locale->text('Design custom report queries')); } sub action_edit { my ($self) = @_; - my $title = $self->query->id ? t8('Edit custom data export query') : t8('Add custom data export query'); + my $title = $self->query->id ? t8('Edit custom report query') : t8('Add custom report query'); $self->setup_edit_action_bar; $self->render('custom_data_export_designer/edit', title => $title); @@ -42,7 +42,7 @@ sub action_edit { sub action_edit_parameters { my ($self) = @_; - my $title = $self->query->id ? t8('Edit custom data export query') : t8('Add custom data export query'); + my $title = $self->query->id ? t8('Edit custom report query') : t8('Add custom report query'); my @parameters = $self->gather_query_data; $self->setup_edit_parameters_action_bar; @@ -58,7 +58,7 @@ sub action_save { $self->query->save; - flash_later('info', t8('The custom data export has been saved.')); + flash_later('info', t8('The custom report has been saved.')); $self->redirect_to($self->url_for(action => 'list')); } @@ -68,7 +68,7 @@ sub action_delete { $self->query->delete; - flash_later('info', t8('The custom data export has been deleted.')); + flash_later('info', t8('The custom report has been deleted.')); $self->redirect_to($self->url_for(action => 'list')); } diff --git a/SL/Controller/CustomVariableConfig.pm b/SL/Controller/CustomVariableConfig.pm index 193523a85..fb68c03aa 100644 --- a/SL/Controller/CustomVariableConfig.pm +++ b/SL/Controller/CustomVariableConfig.pm @@ -11,6 +11,7 @@ use SL::DB::CustomVariableValidity; use SL::DB::PartsGroup; use SL::Helper::Flash; use SL::Locale::String; +use SL::Presenter::CustomVariableConfig; use Data::Dumper; use Rose::Object::MakeMethods::Generic ( @@ -38,6 +39,8 @@ our %translations = ( our @types = qw(text textfield htmlfield number date bool select customer vendor part); # timestamp +our @modules = qw(CT Contacts IC Projects RequirementSpecs ShipTo); + # # actions # @@ -48,6 +51,7 @@ sub action_list { my $configs = SL::DB::Manager::CustomVariableConfig->get_all_sorted(where => [ module => $self->module ]); $self->setup_list_action_bar; + $::form->{title} = t8('List of custom variables'); $::form->header; $self->render('custom_variable_config/list', title => t8('List of custom variables'), @@ -161,13 +165,10 @@ sub init_translated_types { sub init_modules { my ($self, %params) = @_; - return [ sort { $a->{description}->translated cmp $b->{description}->translated } ( - { module => 'CT', description => t8('Customers and vendors') }, - { module => 'Contacts', description => t8('Contact persons') }, - { module => 'IC', description => t8('Parts, services and assemblies') }, - { module => 'Projects', description => t8('Projects') }, - { module => 'RequirementSpecs', description => t8('Requirement Specs') }, - { module => 'ShipTo', description => t8('Shipping Address') }, + return [ + sort { $a->{description}->translated cmp $b->{description}->translated } ( + map +{ module => $_, description => $SL::Presenter::CustomVariableConfig::t8{$_} }, + @modules )]; } diff --git a/SL/Controller/CustomerVendor.pm b/SL/Controller/CustomerVendor.pm index f9e251578..f1ec711e5 100644 --- a/SL/Controller/CustomerVendor.pm +++ b/SL/Controller/CustomerVendor.pm @@ -17,6 +17,7 @@ use SL::Controller::Helper::GetModels; use SL::Controller::Helper::ReportGenerator; use SL::Controller::Helper::ParseFilter; +use SL::DB::AuthGroup; use SL::DB::Customer; use SL::DB::Vendor; use SL::DB::Business; @@ -265,10 +266,16 @@ sub _save { if ( !$self->{note_followup}->follow_up_date ) { $::form->error($::locale->text('Date missing!')); } + if (!$self->{note_followup}->{created_for_employees}) { + $::form->error($::locale->text('You must chose a user.')); + } $self->{note}->trans_id($self->{cv}->id); $self->{note}->save(); + if (delete $self->{note_followup}->{not_done}) { + $self->{note_followup}->done->delete if $self->{note_followup}->done; + } $self->{note_followup}->save(); $self->{note_followup_link}->follow_up_id($self->{note_followup}->id); @@ -372,7 +379,7 @@ sub _transaction { my $db = $self->is_vendor() ? 'vendor' : 'customer'; my $action = 'add'; - if ($::instance_conf->get_feature_experimental_order && 'oe.pl' eq $script) { + if ('oe.pl' eq $script) { $script = 'controller.pl'; $action = 'Order/' . $action; } @@ -844,6 +851,46 @@ sub action_ajax_list_prices { $self->report_generator_list_objects(report => $report, objects => $prices, layout => 0, header => 0); } +# open the dialog for customer/vendor details +# called from SL::Presenter::customer_vendor +sub action_show_customer_vendor_details_dialog { + my ($self) = @_; + + my $is_customer = 'customer' eq $::form->{cv}; + my $cv; + if ($is_customer) { + $cv = SL::DB::Customer->new(id => $::form->{cv_id})->load; + } else { + $cv = SL::DB::Vendor->new(id => $::form->{cv_id})->load; + } + + my %details = map { $_ => $cv->$_ } @{$cv->meta->columns}; + $details{discount_as_percent} = $cv->discount_as_percent; + $details{creditlimt} = $cv->creditlimit_as_number; + $details{business} = $cv->business->description if $cv->business; + $details{language} = $cv->language_obj->description if $cv->language_obj; + $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term; + $details{payment_terms} = $cv->payment->description if $cv->payment; + $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup; + + if ($is_customer) { + foreach my $entry (@{ $cv->additional_billing_addresses }) { + push @{ $details{ADDITIONAL_BILLING_ADDRESSES} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} }; + } + } + foreach my $entry (@{ $cv->shipto }) { + push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} }; + } + foreach my $entry (@{ $cv->contacts }) { + push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} }; + } + + $_[0]->render('common/show_vc_details', { layout => 0 }, + is_customer => $is_customer, + %details); + +} + sub is_vendor { return $::form->{db} eq 'vendor'; } @@ -860,7 +907,7 @@ sub is_orphaned { } my $arap = $self->is_vendor ? 'ap' : 'ar'; - my $num_args = 3; + my $num_args = 4; my $cv = $self->is_vendor ? 'vendor' : 'customer'; @@ -882,7 +929,13 @@ sub is_orphaned { SELECT a.id FROM delivery_orders a JOIN '. $cv .' ct ON (a.'. $cv .'_id = ct.id) - WHERE ct.id = ?'; + WHERE ct.id = ? + + UNION + + SELECT id + FROM price_rule_items + WHERE type LIKE \''. $cv .'\' AND value_int = ?'; if ( $self->is_vendor ) { @@ -934,6 +987,7 @@ sub _instantiate_args { $self->{note} = SL::DB::Note->new(id => $::form->{note}->{id})->load(); $self->{note_followup} = $self->{note}->follow_up; $self->{note_followup_link} = $self->{note_followup}->follow_up_link; + } else { $self->{note} = SL::DB::Note->new(); $self->{note_followup} = SL::DB::FollowUp->new(); @@ -948,6 +1002,13 @@ sub _instantiate_args { $self->{note_followup}->note($self->{note}); $self->{note_followup}->created_by($curr_employee->id); + if (delete $::form->{note_followup_done}) { + $self->{note_followup}->done(SL::DB::FollowUpDone->new) if !$self->{note_followup}->done; + $self->{note_followup}->done->employee_id(SL::DB::Manager::Employee->current->id); + } else { + $self->{note_followup}->{not_done} = 1; + } + $self->{note_followup_link}->trans_type($self->is_vendor() ? 'vendor' : 'customer'); $self->{note_followup_link}->trans_info($self->{cv}->name); @@ -1081,7 +1142,8 @@ sub _pre_render { $self->{all_business} = SL::DB::Manager::Business->get_all(); - $self->{all_employees} = SL::DB::Manager::Employee->get_all(query => [ deleted => 0 ]); + $self->{all_employees} = SL::DB::Manager::Employee->get_all_sorted(query => [ deleted => 0 ]); + $self->{all_auth_groups} = SL::DB::Manager::AuthGroup->get_all_sorted; $self->{all_greetings} = SL::DB::Manager::Greeting->get_all_sorted(); if ($self->{cv}->id && $self->{cv}->greeting && !grep {$self->{cv}->greeting eq $_->description} @{$self->{all_greetings}}) { @@ -1205,7 +1267,7 @@ sub _pre_render { $self->{template_args} ||= {}; - $::request->{layout}->add_javascripts("$_.js") for qw (kivi.CustomerVendor kivi.File kivi.CustomerVendorTurnover ckeditor/ckeditor ckeditor/adapters/jquery); + $::request->{layout}->add_javascripts("$_.js") for qw (kivi.CustomerVendor kivi.File chart kivi.CustomerVendorTurnover follow_up); $self->_setup_form_action_bar; } diff --git a/SL/Controller/CustomerVendorTurnover.pm b/SL/Controller/CustomerVendorTurnover.pm index 5a59f1fbd..c7cc25aa6 100644 --- a/SL/Controller/CustomerVendorTurnover.pm +++ b/SL/Controller/CustomerVendorTurnover.pm @@ -1,14 +1,18 @@ package SL::Controller::CustomerVendorTurnover; use strict; use parent qw(SL::Controller::Base); + +use List::Util qw(first); + use SL::DBUtils; use SL::DB::AccTransaction; use SL::DB::Invoice; use SL::DB::Order; +use SL::DB::Order::TypeData qw(:types); use SL::DB::EmailJournal; use SL::DB::Letter; use SL::DB; - +use SL::JSON qw(to_json); __PACKAGE__->run_before('check_auth'); sub action_list_turnover { @@ -47,7 +51,7 @@ sub action_list_turnover { $open_items = $self->_list_open_items($open_invoices); } my $open_orders = $self->_get_open_orders; - return $self->render('customer_vendor_turnover/turnover', { header => 0 }, + return $self->render('customer_vendor_turnover/turnover', { layout => 0 }, open_orders => $open_orders, open_items => $open_items, id => $cv, @@ -112,12 +116,20 @@ SQL $self->render('customer_vendor_turnover/count_open_items_by_year', { layout => 0 }); } -sub action_turnover_by_month { +sub action_turnover { my ($self) = @_; return $self->render('generic/error', { layout => 0 }, label_error => "list_transactions needs a trans_id") unless $::form->{id}; + my $sort_dir = 'DESC'; + my $fill_holes = 0; + + if ($::request->type eq 'json') { + $sort_dir = 'ASC'; + $fill_holes = 1; + } + my $dbh = SL::DB->client->dbh; my $cv = $::form->{id}; my ($db, $cv_type); @@ -128,47 +140,114 @@ sub action_turnover_by_month { $db = "ap"; $cv_type = "vendor_id"; } - my $query = <{turnover_statistic} = selectall_hashref_query($::form, $dbh, $query, $cv); - $self->render('customer_vendor_turnover/count_turnover', { layout => 0 }); -} -sub action_turnover_by_year { - my ($self) = @_; + my $year_where = ('month' eq $::form->{mode} && $::form->{year}) + ? 'AND EXTRACT (YEAR FROM transdate) = ?' + : ''; - return $self->render('generic/error', { layout => 0 }, label_error => "list_transactions needs a trans_id") unless $::form->{id}; - my $dbh = SL::DB->client->dbh; - my $cv = $::form->{id}; - my ($db, $cv_type); - if ($::form->{db} eq 'customer') { - $db = "ar"; - $cv_type = "customer_id"; + my ($date_part_select, $group_by, $order_by); + if ('month' eq $::form->{mode}) { + $date_part_select = "CONCAT(EXTRACT (MONTH FROM transdate),'/',EXTRACT (YEAR FROM transdate))"; + $group_by = "EXTRACT (YEAR FROM transdate), EXTRACT (MONTH FROM transdate)"; + $order_by = "EXTRACT (YEAR FROM transdate) $sort_dir, EXTRACT (MONTH FROM transdate) $sort_dir"; } else { - $db = "ap"; - $cv_type = "vendor_id"; + $date_part_select = "EXTRACT (YEAR FROM transdate)"; + $group_by = "EXTRACT (YEAR FROM transdate)"; + $order_by = "EXTRACT (YEAR FROM transdate) $sort_dir"; } + my $query = <{turnover_statistic} = selectall_hashref_query($::form, $dbh, $query, $cv); - $self->render('customer_vendor_turnover/count_turnover', { layout => 0 }); + $self->{turnover_statistic} = selectall_hashref_query($::form, $dbh, $query, $cv, ($::form->{year} || '') x !!('month' eq $::form->{mode} && $::form->{year})); + + if ('month' eq $::form->{mode} && $fill_holes && ($::form->{year} || @{$self->{turnover_statistic}} > 1)) { + my $date_part_to_months = sub { my ($m, $y) = $_[0] =~ m{^(\d{1,2})/(\d{1,4})$}; return $m + 12*$y; }; + my $months_to_date_part = sub { my $y = int(($_[0] - 1)/12); my $m = $_[0] - 12*$y; $m ||= 12; return "$m/$y"; }; + + my $start_month; + my $end_month; + if (!$::form->{year}) { + $start_month = $date_part_to_months->($self->{turnover_statistic}[ 0]->{date_part}); + $end_month = $date_part_to_months->($self->{turnover_statistic}[-1]->{date_part}); + + } else { + if ($sort_dir eq 'ASC') { + $start_month = $date_part_to_months->('1/' . $::form->{year}); + $end_month = $date_part_to_months->('12/' . $::form->{year}); + } else { + $start_month = $date_part_to_months->('12/' . $::form->{year}); + $end_month = $date_part_to_months->('1/' . $::form->{year}); + } + } + + my $step = ($start_month > $end_month) ? -1 : 1; + my @range = ($step == 1) ? ($start_month .. $end_month) : reverse ($end_month .. $start_month); + my @new_stats = (); + + my %stats_by_month = map { $date_part_to_months->($_->{date_part}) => $_ } grep { $_ } @{$self->{turnover_statistic} || []}; + foreach my $month (@range) { + if ($stats_by_month{$month}) { + push @new_stats, $stats_by_month{$month}; + } else { + push @new_stats, {date_part => $months_to_date_part->($month)}; + } + } + + $self->{turnover_statistic} = \@new_stats; + } + + if ('month' ne $::form->{mode} && $fill_holes && @{$self->{turnover_statistic}} > 1) { + my $start = $self->{turnover_statistic}[ 0]->{date_part}; + my $end = $self->{turnover_statistic}[-1]->{date_part}; + my $step = ($start > $end) ? -1 : 1; + my $next_date_part = $start; + my @new_stats = (); + + foreach my $stat (@{$self->{turnover_statistic}}) { + while ($stat->{date_part} != $next_date_part) { + push @new_stats, {date_part => $next_date_part}; + $next_date_part += $step; + } + push @new_stats, $stat; + $next_date_part += $step; + } + + $self->{turnover_statistic} = \@new_stats; + } + + if (@{$self->{turnover_statistic}} > 1) { + my $query = <{turnover_statistic}}) { + my $overall_stat = first { $_->{date_part} eq $stat->{date_part} } @$overall_turnover; + $stat->{overall_netamount} = 0; + $stat->{'overall_' . $_} = $overall_stat->{$_} for keys %$overall_stat; + } + } + + if ($::request->type eq 'json') { + $self->render(\ SL::JSON::to_json($self->{turnover_statistic}), { layout => 0, type => 'json', process => 0 }); + } else { + $self->render('customer_vendor_turnover/count_turnover', { layout => 0 }); + } } sub action_get_invoices { @@ -204,7 +283,7 @@ sub action_get_orders { $orders = SL::DB::Manager::Order->get_all( query => [ customer_id => $cv, - quotation => ($type eq 'quotation' ? 'T' : 'F') + record_type => ($type eq 'quotation' ? SALES_QUOTATION_TYPE() : SALES_ORDER_TYPE()) ], sort_by => 'transdate DESC', ); @@ -212,7 +291,7 @@ sub action_get_orders { $orders = SL::DB::Manager::Order->get_all( query => [ vendor_id => $cv, - quotation => ($type eq 'quotation' ? 'T' : 'F') + record_type => ($type eq 'quotation' ? REQUEST_QUOTATION_TYPE() : PURCHASE_ORDER_TYPE()) ], sort_by => 'transdate DESC', ); @@ -268,7 +347,7 @@ sub action_get_mails { $query = < +=item C gets and shows an invoice statistic of customer/vendor by month - -=item C - -gets and shows an invoice statistic of customer/vendor by year +or year depending on $::form->{mode}. If $::form->{mode} eq 'month' +get statistics by month, otherwise by year. =item C diff --git a/SL/Controller/DeliveryOrder.pm b/SL/Controller/DeliveryOrder.pm index 7c4a99bfa..0603113a1 100644 --- a/SL/Controller/DeliveryOrder.pm +++ b/SL/Controller/DeliveryOrder.pm @@ -15,30 +15,46 @@ use SL::File; use SL::MIME; use SL::Util qw(trim); use SL::YAML; +use SL::DBUtils qw(selectall_hashref_query); use SL::DB::History; -use SL::DB::Order; use SL::DB::Default; use SL::DB::Unit; use SL::DB::Order; +use SL::DB::Order::TypeData qw(:types); use SL::DB::Part; use SL::DB::PartClassification; use SL::DB::PartsGroup; use SL::DB::Printer; use SL::DB::Language; +use SL::DB::Reclamation; +use SL::DB::Reclamation::TypeData qw(:types); use SL::DB::RecordLink; use SL::DB::Shipto; use SL::DB::Translation; use SL::DB::TransferType; +use SL::DB::ValidityToken; +use SL::DB::EmailJournal; +use SL::DB::Warehouse; +use SL::DB::Bin; +use SL::DB::Helper::RecordLink qw(set_record_link_conversions RECORD_ID RECORD_TYPE_REF RECORD_ITEM_ID RECORD_ITEM_TYPE_REF); +use SL::DB::Helper::TypeDataProxy; +use SL::DB::Helper::Record qw(get_object_name_from_type get_class_from_type); +use SL::DB::DeliveryOrder; +use SL::DB::DeliveryOrder::TypeData qw(:types); +use SL::DB::Manager::DeliveryOrderItem; +use SL::DB::DeliveryOrderItemsStock; +use SL::Model::Record; use SL::Helper::CreatePDF qw(:all); use SL::Helper::PrintOptions; use SL::Helper::ShippedQty; +use SL::Helper::Inventory; +use SL::Helper::DateTime; use SL::Helper::UserPreferences::DisplayPreferences; use SL::Helper::UserPreferences::PositionsScrollbar; use SL::Helper::UserPreferences::UpdatePositions; use SL::Controller::Helper::GetModels; -use SL::Controller::DeliveryOrder::TypeData qw(:types); use List::Util qw(first sum0); use List::UtilsBy qw(sort_by uniq_by); @@ -48,23 +64,32 @@ use File::Spec; use Cwd; use Sort::Naturally; -use Rose::Object::MakeMethods::Generic -( - scalar => [ qw(item_ids_to_delete is_custom_shipto_to_delete) ], - 'scalar --get_set_init' => [ qw(order valid_types type cv p all_price_factors search_cvpartnumber show_update_button part_picker_classification_ids type_data) ], +use Rose::Object::MakeMethods::Generic ( + scalar => [qw(item_ids_to_delete is_custom_shipto_to_delete)], + 'scalar --get_set_init' => [ qw( + order valid_types type cv p all_price_factors search_cvpartnumber + show_update_button part_picker_classification_ids type_data + ) ], ); # safety __PACKAGE__->run_before('check_auth', - except => [ qw(update_stock_information) ]); + except => [ qw( + update_stock_information + ) ]); __PACKAGE__->run_before('check_auth_for_edit', - except => [ qw(update_stock_information edit show_customer_vendor_details_dialog price_popup stock_in_out_dialog load_second_rows) ]); + except => [ qw( + update_stock_information edit + stock_in_out_dialog load_second_rows + ) ]); __PACKAGE__->run_before('get_unalterable_data', - only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction - print send_email) ]); + only => [ qw( + save save_as_new workflow_new_record workflow_invoice + save_and_ap_transaction print send_email + ) ]); # # actions @@ -74,11 +99,14 @@ __PACKAGE__->run_before('get_unalterable_data', sub action_add { my ($self) = @_; - $self->order->transdate(DateTime->now_local()); - $self->type_data->set_reqdate_by_type; + $self->pre_render(); + if (!$::form->{form_validity_token}) { + $::form->{form_validity_token} = SL::DB::ValidityToken->create( + scope => SL::DB::ValidityToken::SCOPE_DELIVERY_ORDER_SAVE() + )->token; + } - $self->pre_render(); $self->render( 'delivery_order/form', title => $self->get_title_for('add'), @@ -86,39 +114,70 @@ sub action_add { ); } -sub action_add_from_order { +sub action_add_from_record { my ($self) = @_; - # this interfers with init_order - $self->{converted_from_oe_id} = delete $::form->{id}; + my $from_type = $::form->{from_type}; + my $from_id = $::form->{from_id}; + + die "No 'from_type' was given." unless ($from_type); + die "No 'from_id' was given." unless ($from_id); + + my %flags = (); + if (defined($::form->{from_item_ids})) { + my %use_item = map { $_ => 1 } @{$::form->{from_item_ids}}; + $flags{item_filter} = sub { + my ($item) = @_; + return %use_item{$item->{RECORD_ITEM_ID()}}; + } + } + + my $record = SL::Model::Record->get_record($from_type, $from_id); - $self->type_data->validate($::form->{type}); + # If we are coming from an order workflow, only consider not delivered + # quantities. + if (ref $record eq 'SL::DB::Order') { + # Calculate shipped qtys here to prevent calling calculate for every item + # via the items method. + SL::Helper::ShippedQty->new->calculate($record)->write_to(\@{$record->items}); - my $order = SL::DB::Order->new(id => $self->{converted_from_oe_id})->load; + my @items_with_not_delivered_qty = + grep {$_->qty > 0} + map {$_->qty($_->qty - $_->shipped_qty); $_} + @{$record->items_sorted}; - $self->order(SL::DB::DeliveryOrder->new_from($order, type => $::form->{type})); + $flags{items} = \@items_with_not_delivered_qty; + } + + my $delivery_order = SL::Model::Record->new_from_workflow($record, $self->type, %flags); + $self->order($delivery_order); + $self->reinit_after_new_order(); $self->action_add; } -# edit an existing order -sub action_edit { +sub action_add_from_email_journal { my ($self) = @_; + die "No 'email_journal_id' was given." unless ($::form->{email_journal_id}); - if ($::form->{id}) { - $self->load_order; + $self->action_add(); +} - } else { - # this is to edit an order from an unsaved order object +sub action_edit_with_email_journal_workflow { + my ($self) = @_; + die "No 'email_journal_id' was given." unless ($::form->{email_journal_id}); + $::form->{workflow_email_journal_id} = delete $::form->{email_journal_id}; + $::form->{workflow_email_attachment_id} = delete $::form->{email_attachment_id}; + $::form->{workflow_email_callback} = delete $::form->{callback}; - # set item ids to new fake id, to identify them as new items - foreach my $item (@{$self->order->items_sorted}) { - $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000); - } - # trigger rendering values for second row as hidden, because they - # are loaded only on demand. So we need to keep the values from - # the source. - $_->{render_second_row} = 1 for @{ $self->order->items_sorted }; - } + $self->action_edit(); +} + +# edit an existing order +sub action_edit { + my ($self) = @_; + die "No 'id' was given." unless $::form->{id}; + + $self->load_order; $self->pre_render(); $self->render( @@ -128,47 +187,11 @@ sub action_edit { ); } -# edit a collective order (consisting of one or more existing orders) -sub action_edit_collective { - my ($self) = @_; - - # collect order ids - my @multi_ids = map { - $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1} - } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form; - - # fall back to add if no ids are given - if (scalar @multi_ids == 0) { - $self->action_add(); - return; - } - - # fall back to save as new if only one id is given - if (scalar @multi_ids == 1) { - $self->order(SL::DB::DeliveryOrder->new(id => $multi_ids[0])->load); - $self->action_save_as_new(); - return; - } - - # make new order from given orders - my @multi_orders = map { SL::DB::DeliveryOrder->new(id => $_)->load } @multi_ids; - $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders; - $self->order(SL::DB::DeliveryOrder->new_from_multi(\@multi_orders, sort_sources_by => 'transdate')); - - $self->action_edit(); -} - # delete the order sub action_delete { my ($self) = @_; - my $errors = $self->delete(); - - if (scalar @{ $errors }) { - $self->js->flash('error', $_) foreach @{ $errors }; - return $self->js->render(); - } - + SL::Model::Record->delete($self->order); flash_later('info', $self->type_data->text("delete")); my @redirect_params = ( @@ -183,20 +206,28 @@ sub action_delete { sub action_save { my ($self) = @_; - my $errors = $self->save(); - - if (scalar @{ $errors }) { - $self->js->flash('error', $_) foreach @{ $errors }; + if ( $self->order->delivered ) { + $self->js->flash('error', t8('This record has already been delivered.')); return $self->js->render(); } + $self->save(); + flash_later('info', $self->type_data->text("saved")); - my @redirect_params = ( - action => 'edit', - type => $self->type, - id => $self->order->id, - ); + my @redirect_params; + if ($::form->{back_to_caller}) { + @redirect_params = $::form->{callback} ? ($::form->{callback}) + : (controller => 'LoginScreen', action => 'user_login'); + + } else { + @redirect_params = ( + action => 'edit', + type => $self->type, + id => $self->order->id, + callback => $::form->{callback}, + ); + } $self->redirect_to(@redirect_params); } @@ -212,37 +243,38 @@ sub action_save_as_new { return $self->js->render(); } - # load order from db to check if values changed my $saved_order = SL::DB::DeliveryOrder->new(id => $order->id)->load; - my %new_attrs; - # Lets assign a new number if the user hasn't changed the previous one. - # If it has been changed manually then use it as-is. - $new_attrs{number} = (trim($order->number) eq $saved_order->number) - ? '' - : trim($order->number); - - # Clear transdate unless changed - $new_attrs{transdate} = ($order->transdate == $saved_order->transdate) - ? DateTime->today_local - : $order->transdate; - - # Set new reqdate unless changed if it is enabled in client config - $new_attrs{reqdate} = $self->type_data->get_reqdate_by_type($order->reqdate, $saved_order->reqdate); - - # Update employee - $new_attrs{employee} = SL::DB::Manager::Employee->current; - # Create new record from current one - $self->order(SL::DB::DeliveryOrder->new_from($order, destination_type => $order->type, attributes => \%new_attrs)); + my $new_order = SL::Model::Record->clone_for_save_as_new($saved_order, $order); + $self->order($new_order); - # no linked records on save as new - delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids); + if (!$::form->{form_validity_token}) { + $::form->{form_validity_token} = SL::DB::ValidityToken->create( + scope => SL::DB::ValidityToken::SCOPE_DELIVERY_ORDER_SAVE() + )->token; + } # save $self->action_save(); } +# close a already saved order (potentially already delivered) +sub action_close_order { + my ($self) = @_; + + $self->order->update_attributes( + closed => 1 + ); + + $self->js + ->flash("info", t8("The record has been closed.")) + ->run('kivi.ActionBar.setDisabled', '#close_order', + t8('This record has already been closed.')) + ->html('#data-status-line', delivery_order_status_line($self->order)) + ->render +} + # print the order # # This is called if "print" is pressed in the print dialog. @@ -251,14 +283,16 @@ sub action_save_as_new { sub action_print { my ($self) = @_; - my $errors = $self->save(); - - if (scalar @{ $errors }) { - $self->js->flash('error', $_) foreach @{ $errors }; - return $self->js->render(); + if ( !$self->order->delivered ) { + $self->save(); + $self->js_reset_order_and_item_ids_after_save; } - $self->js_reset_order_and_item_ids_after_save; + my $redirect_url = $self->url_for( + action => 'edit', + type => $self->type, + id => $self->order->id, + ); my $format = $::form->{print_options}->{format}; my $media = $::form->{print_options}->{media}; @@ -269,12 +303,14 @@ sub action_print { # only pdf and opendocument by now if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf)) { - return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render; + flash_later('error', t8('Format \'#1\' is not supported yet/anymore.', $format)); + return $self->js->redirect_to($redirect_url)->render; } # only screen or printer by now if (none { $media eq $_ } qw(screen printer)) { - return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render; + flash_later('error', t8('Media \'#1\' is not supported yet/anymore.', $media)); + return $self->js->redirect_to($redirect_url)->render; } # create a form for generate_attachment_filename @@ -283,22 +319,25 @@ sub action_print { $form->{type} = $self->type; $form->{format} = $format; $form->{formname} = $formname; - $form->{language} = '_' . $self->order->language->template_code if $self->order->language; + $form->{language} = + '_' . $self->order->language->template_code if $self->order->language; my $pdf_filename = $form->generate_attachment_filename(); - my $pdf; - my @errors = generate_pdf($self->order, \$pdf, { format => $format, - formname => $formname, - language => $self->order->language, - printer_id => $printer_id, - groupitems => $groupitems }); + my @errors = generate_pdf($self->order, \$pdf, { + format => $format, + formname => $formname, + language => $self->order->language, + printer_id => $printer_id, + groupitems => $groupitems + }); if (scalar @errors) { - return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render; + flash_later('error', t8('Generating the document failed: #1', $errors[0])); + return $self->js->redirect_to($redirect_url)->render; } if ($media eq 'screen') { # screen/download - $self->js->flash('info', t8('The PDF has been created')); + flash_later('info', t8('The document has been created.')); $self->send_file( \$pdf, type => SL::MIME->mime_type_from_ext($pdf_filename), @@ -314,30 +353,34 @@ sub action_print { content => $pdf, ); - $self->js->flash('info', t8('The PDF has been printed')); + flash_later('info', t8('The document has been printed.')); } - my @warnings = store_pdf_to_webdav_and_filemanagement($self->order, $pdf, $pdf_filename); + my @warnings = store_pdf_to_webdav_and_filemanagement( + $self->order, $pdf, $pdf_filename, $formname + ); if (scalar @warnings) { - $self->js->flash('warning', $_) for @warnings; + flash_later('warning', $_) for @warnings; } $self->save_history('PRINTED'); - $self->js - ->run('kivi.ActionBar.setEnabled', '#save_and_email_action') - ->render; + $self->js->redirect_to($redirect_url)->render; } + sub action_preview_pdf { my ($self) = @_; - my $errors = $self->save(); - if (scalar @{ $errors }) { - $self->js->flash('error', $_) foreach @{ $errors }; - return $self->js->render(); + if ( !$self->order->delivered ) { + $self->save(); + $self->js_reset_order_and_item_ids_after_save; } - $self->js_reset_order_and_item_ids_after_save; + my $redirect_url = $self->url_for( + action => 'edit', + type => $self->type, + id => $self->order->id, + ); my $format = 'pdf'; my $media = 'screen'; @@ -350,103 +393,138 @@ sub action_preview_pdf { $form->{type} = $self->type; $form->{format} = $format; $form->{formname} = $formname; - $form->{language} = '_' . $self->order->language->template_code if $self->order->language; + $form->{language} = + '_' . $self->order->language->template_code if $self->order->language; my $pdf_filename = $form->generate_attachment_filename(); my $pdf; - my @errors = generate_pdf($self->order, \$pdf, { format => $format, - formname => $formname, - language => $self->order->language, - }); + my @errors = generate_pdf($self->order, \$pdf, { + format => $format, + formname => $formname, + language => $self->order->language, + }); if (scalar @errors) { - return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render; + flash_later('error', t8('Conversion to PDF failed: #1', $errors[0])); + return $self->js->redirect_to($redirect_url)->render; } $self->save_history('PREVIEWED'); - $self->js->flash('info', t8('The PDF has been previewed')); + flash_later('info', t8('The PDF has been previewed')); # screen/download $self->send_file( \$pdf, type => SL::MIME->mime_type_from_ext($pdf_filename), name => $pdf_filename, - js_no_render => 0, + js_no_render => 1, ); + $self->js->redirect_to($redirect_url)->render; } # open the email dialog sub action_save_and_show_email_dialog { my ($self) = @_; - my $errors = $self->save(); - - if (scalar @{ $errors }) { - $self->js->flash('error', $_) foreach @{ $errors }; - return $self->js->render(); - } - - my $cv_method = $self->cv; - - if (!$self->order->$cv_method) { - return $self->js->flash('error', $self->cv eq 'customer' ? t8('Cannot send E-mail without customer given') : t8('Cannot send E-mail without vendor given')) - ->render($self); + if (!$self->order->delivered) { + $self->save(); + $self->js_reset_order_and_item_ids_after_save; } - my $email_form; - $email_form->{to} = $self->order->contact->cp_email if $self->order->contact; - $email_form->{to} ||= $self->order->$cv_method->email; - $email_form->{cc} = $self->order->$cv_method->cc; - $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc; - # Todo: get addresses from shipto, if any + my $cv = $self->order->customervendor + or return $self->js->flash('error', + $self->cv eq 'customer' ? + t8('Cannot send E-mail without customer given') + : t8('Cannot send E-mail without vendor given') + )->render($self); my $form = Form->new; $form->{$self->nr_key()} = $self->order->number; $form->{cusordnumber} = $self->order->cusordnumber; $form->{formname} = $self->type; $form->{type} = $self->type; - $form->{language} = '_' . $self->order->language->template_code if $self->order->language; - $form->{language_id} = $self->order->language->id if $self->order->language; + $form->{language} = + '_' . $self->order->language->template_code if $self->order->language; + $form->{language_id} = + $self->order->language->id if $self->order->language; $form->{format} = 'pdf'; - $form->{cp_id} = $self->order->contact->cp_id if $self->order->contact; + $form->{cp_id} = + $self->order->contact->cp_id if $self->order->contact; + my $email_form; + $email_form->{to} = + ($self->order->contact ? $self->order->contact->cp_email : undef) + || ($cv->is_customer ? $cv->delivery_order_mail : undef) + || $cv->email; + $email_form->{cc} = $cv->cc; + $email_form->{bcc} = join ', ', grep $_, $cv->bcc; + # Todo: get addresses from shipto, if any $email_form->{subject} = $form->generate_email_subject(); $email_form->{attachment_filename} = $form->generate_attachment_filename(); $email_form->{message} = $form->generate_email_body(); $email_form->{js_send_function} = 'kivi.DeliveryOrder.send_email()'; my %files = $self->get_files_for_email_dialog(); - $self->{all_employees} = SL::DB::Manager::Employee->get_all(query => [ deleted => 0 ]); - my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 }, - email_form => $email_form, - show_bcc => $::auth->assert('email_bcc', 'may fail'), - FILES => \%files, - is_customer => $self->type_data->is_customer, - ALL_EMPLOYEES => $self->{all_employees}, + + my @employees_with_email = grep { + my $user = SL::DB::Manager::AuthUser->find_by(login => $_->login); + $user && !!trim($user->get_config_value('email')); + } @{ SL::DB::Manager::Employee->get_all_sorted(query => [ deleted => 0 ]) }; + + my $dialog_html = $self->render( + 'common/_send_email_dialog', { output => 0 }, + email_form => $email_form, + show_bcc => $::auth->assert('email_bcc', 'may fail'), + FILES => \%files, + is_customer => $self->type_data->properties("is_customer"), + ALL_EMPLOYEES => \@employees_with_email, + ALL_PARTNER_EMAIL_ADDRESSES => $cv->get_all_email_addresses(), ); $self->js - ->run('kivi.DeliveryOrder.show_email_dialog', $dialog_html) - ->reinit_widgets - ->render($self); + ->run('kivi.DeliveryOrder.show_email_dialog', $dialog_html) + ->reinit_widgets + ->render($self); } # send email -# -# Todo: handling error messages: flash is not displayed in dialog, but in the main form sub action_send_email { my ($self) = @_; - my $errors = $self->save(); - - if (scalar @{ $errors }) { - $self->js->run('kivi.DeliveryOrder.close_email_dialog'); - $self->js->flash('error', $_) foreach @{ $errors }; - return $self->js->render(); + if ( !$self->order->delivered ) { + eval { + $self->save(); + 1; + } or do { + $self->js->run('kivi.Order.close_email_dialog'); + die $EVAL_ERROR; + }; } - $self->js_reset_order_and_item_ids_after_save; + my @redirect_params = ( + action => 'edit', + type => $self->type, + id => $self->order->id, + ); + + # Set the error handler to reload the document and display errors later, + # because the document is already saved and saving can have some side effects + # such as generating a document number, project number or record links, + # which will be up to date when the document is reloaded. + # Hint: Do not use "die" here and try to catch exceptions in subroutine + # calls. You should use "$::form->error" which respects the error handler. + local $::form->{__ERROR_HANDLER} = sub { + flash_later('error', $_[0]); + $self->redirect_to(@redirect_params); + $::dispatcher->end_request; + }; + # move $::form->{email_form} to $::form my $email_form = delete $::form->{email_form}; - my %field_names = (to => 'email'); + if ($email_form->{additional_to}) { + $email_form->{to} = join ', ', grep { $_ } $email_form->{to}, @{$email_form->{additional_to}}; + delete $email_form->{additional_to}; + } + + my %field_names = (to => 'email'); $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form }; # for Form::cleanup which may be called in Form::send_email @@ -456,19 +534,37 @@ sub action_send_email { $::form->{$_} = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} }; $::form->{media} = 'email'; - if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) { + $::form->{attachment_policy} //= ''; + + # Is an old file version available? + my $attfile; + if ($::form->{attachment_policy} eq 'old_file') { + $attfile = SL::File->get_all( + object_id => $self->order->id, + object_type => $::form->{formname}, + file_type => 'document', + print_variant => $::form->{formname}, + ); + } + + if ( $::form->{attachment_policy} ne 'no_file' + && !($::form->{attachment_policy} eq 'old_file' && $attfile)) { my $pdf; - my @errors = generate_pdf($self->order, \$pdf, {media => $::form->{media}, - format => $::form->{print_options}->{format}, - formname => $::form->{print_options}->{formname}, - language => $self->order->language, - printer_id => $::form->{print_options}->{printer_id}, - groupitems => $::form->{print_options}->{groupitems}}); + my @errors = generate_pdf($self->order, \$pdf, { + media => $::form->{media}, + format => $::form->{print_options}->{format}, + formname => $::form->{print_options}->{formname}, + language => $self->order->language, + printer_id => $::form->{print_options}->{printer_id}, + groupitems => $::form->{print_options}->{groupitems}}, + ); if (scalar @errors) { - return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self); + $::form->error(t8('Generating the document failed: #1', $errors[0])); } - my @warnings = store_pdf_to_webdav_and_filemanagement($self->order, $pdf, $::form->{attachment_filename}); + my @warnings = store_pdf_to_webdav_and_filemanagement( + $self->order, $pdf, $::form->{attachment_filename}, $::form->{formname} + ); if (scalar @warnings) { flash_later('warning', $_) for @warnings; } @@ -478,90 +574,77 @@ sub action_send_email { $sfile->fh->close; $::form->{tmpfile} = $sfile->file_name; - $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email + $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be + # called in Form::send_email } - $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a linked record to the mail + $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a + # linked record to the mail $::form->send_email(\%::myconfig, 'pdf'); + $self->save_history('MAILED'); + flash_later('info', t8('The email has been sent.')); + # internal notes unless no email journal unless ($::instance_conf->get_email_journal) { - my $intnotes = $self->order->intnotes; $intnotes .= "\n\n" if $self->order->intnotes; - $intnotes .= t8('[email]') . "\n"; - $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n"; - $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n"; - $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc}; - $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc}; - $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n"; - $intnotes .= t8('Message') . ": " . $::form->{message}; + $intnotes .= t8('[email]') . "\n"; + $intnotes .= t8('Date') . ": " . + $::locale->format_date_object( + DateTime->now_local, precision => 'seconds' + ) . "\n"; + $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n"; + $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc}; + $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc}; + $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n"; + $intnotes .= t8('Message') . ": " . SL::HTML::Util->strip($::form->{message}); $self->order->update_attributes(intnotes => $intnotes); } - $self->save_history('MAILED'); - - flash_later('info', t8('The email has been sent.')); - - my @redirect_params = ( - action => 'edit', - type => $self->type, - id => $self->order->id, - ); - $self->redirect_to(@redirect_params); } -# save the order and redirect to the frontend subroutine for a new -# delivery order -sub action_save_and_delivery_order { +sub action_workflow_new_record { my ($self) = @_; - - $self->save_and_redirect_to( - controller => 'oe.pl', - action => 'oe_delivery_order_from_order', + my $to_type = $::form->{to_type}; + my $to_controller = get_object_name_from_type($to_type); + + my %additional_params = (); + if ($::form->{only_selected_item_positions}) { # ids can be unset before save + my $item_positions = $::form->{selected_item_positions} || []; + my @from_item_ids = map { $self->order->items_sorted->[$_]->id } @$item_positions; + $additional_params{from_item_ids} = \@from_item_ids; + } + + flash_later('info', $self->type_data->text('saved')); + + $self->redirect_to( + controller => $to_controller, + action => 'add_from_record', + type => $to_type, + from_id => $self->order->id, + from_type => $self->order->type, + email_journal_id => $::form->{workflow_email_journal_id}, + email_attachment_id => $::form->{workflow_email_attachment_id}, + callback => $::form->{workflow_email_callback}, + %additional_params, ); } # save the order and redirect to the frontend subroutine for a new # invoice -sub action_save_and_invoice { - my ($self) = @_; - - $self->save_and_redirect_to( - controller => 'oe.pl', - action => 'oe_invoice_from_order', - ); -} - -# workflow from sales order to sales quotation -sub action_sales_quotation { - $_[0]->workflow_sales_or_request_for_quotation(); -} - -# workflow from sales order to sales quotation -sub action_request_for_quotation { - $_[0]->workflow_sales_or_request_for_quotation(); -} - -# workflow from sales quotation to sales order -sub action_sales_order { - $_[0]->workflow_sales_or_purchase_order(); -} - -# workflow from rfq to purchase order -sub action_purchase_order { - $_[0]->workflow_sales_or_purchase_order(); -} - -# workflow from purchase order to ap transaction -sub action_save_and_ap_transaction { +sub action_workflow_invoice { my ($self) = @_; - $self->save_and_redirect_to( - controller => 'ap.pl', - action => 'add_from_purchase_order', + $self->redirect_to( + controller => 'do.pl', + action => 'invoice_from_delivery_order_controller', + from_id => $self->order->id, + email_journal_id => $::form->{workflow_email_journal_id}, + email_attachment_id => $::form->{workflow_email_attachment_id}, + callback => $::form->{workflow_email_callback}, ); } @@ -571,11 +654,14 @@ sub action_save_and_ap_transaction { sub action_customer_vendor_changed { my ($self) = @_; - setup_order_from_cv($self->order); + $self->order( + SL::Model::Record->update_after_customer_vendor_change($self->order) + ); my $cv_method = $self->cv; - if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) { + if ( $self->order->$cv_method->contacts + && scalar @{ $self->order->$cv_method->contacts } > 0) { $self->js->show('#cp_row'); } else { $self->js->hide('#cp_row'); @@ -608,40 +694,6 @@ sub action_customer_vendor_changed { $self->js->render(); } -# open the dialog for customer/vendor details -sub action_show_customer_vendor_details_dialog { - my ($self) = @_; - - my $is_customer = 'customer' eq $::form->{vc}; - my $cv; - if ($is_customer) { - $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load; - } else { - $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load; - } - - my %details = map { $_ => $cv->$_ } @{$cv->meta->columns}; - $details{discount_as_percent} = $cv->discount_as_percent; - $details{creditlimt} = $cv->creditlimit_as_number; - $details{business} = $cv->business->description if $cv->business; - $details{language} = $cv->language_obj->description if $cv->language_obj; - $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term; - $details{payment_terms} = $cv->payment->description if $cv->payment; - $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup; - - foreach my $entry (@{ $cv->shipto }) { - push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} }; - } - foreach my $entry (@{ $cv->contacts }) { - push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} }; - } - - $_[0]->render('common/show_vc_details', { layout => 0 }, - is_customer => $is_customer, - %details); - -} - # called if a unit in an existing item row is changed sub action_unit_changed { my ($self) = @_; @@ -652,8 +704,11 @@ sub action_unit_changed { my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load; $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj)); - $self->js - ->run('kivi.DeliveryOrder.update_sellprice', $::form->{item_id}, $item->sellprice_as_number); + $self->js->run( + 'kivi.DeliveryOrder.update_sellprice', + $::form->{item_id}, + $item->sellprice_as_number + ); $self->js_redisplay_line_values; $self->js->render(); } @@ -679,12 +734,15 @@ sub action_add_item { ITEM => $item, ID => $item_id, SELF => $self, - in_out => $self->type_data->transfer, + in_out => $self->type_data->properties("transfer"), ); if ($::form->{insert_before_item_id}) { $self->js - ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html); + ->before( + '.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', + $row_as_html + ); } else { $self->js ->append('#row_table_id', $row_as_html); @@ -693,19 +751,26 @@ sub action_add_item { if ( $item->part->is_assortment ) { $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number}; foreach my $assortment_item ( @{$item->part->assortment_items} ) { - my $attr = { parts_id => $assortment_item->parts_id, - qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit} - unit => $assortment_item->unit, - description => $assortment_item->part->description, - }; + my $attr = { + parts_id => $assortment_item->parts_id, + qty => $assortment_item->qty * + $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit} + unit => $assortment_item->unit, + description => $assortment_item->part->description, + }; my $item = new_item($self->order, $attr); - # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount + # set discount to 100% if item isn't supposed to be charged, overwriting + # any customer discount $item->discount(1) unless $assortment_item->charge; $self->order->add_items( $item ); $self->get_item_cvpartnumber($item); - my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000); + my $item_id = join('_', + 'new', + Time::HiRes::gettimeofday(), + int rand 1000000000000 + ); my $row_as_html = $self->p->render('delivery_order/tabs/_row', ITEM => $item, ID => $item_id, @@ -713,7 +778,10 @@ sub action_add_item { ); if ($::form->{insert_before_item_id}) { $self->js - ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html); + ->before( + '.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', + $row_as_html + ); } else { $self->js ->append('#row_table_id', $row_as_html); @@ -745,14 +813,16 @@ sub action_add_multi_items { push @items, $item; if ( $item->part->is_assortment ) { foreach my $assortment_item ( @{$item->part->assortment_items} ) { - my $attr = { parts_id => $assortment_item->parts_id, - qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit} - unit => $assortment_item->unit, - description => $assortment_item->part->description, - }; + my $attr = { + parts_id => $assortment_item->parts_id, + qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit} + unit => $assortment_item->unit, + description => $assortment_item->part->description, + }; my $item = new_item($self->order, $attr); - # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount + # set discount to 100% if item isn't supposed to be charged, overwriting + # any customer discount $item->discount(1) unless $assortment_item->charge; push @items, $item; } @@ -762,17 +832,24 @@ sub action_add_multi_items { foreach my $item (@items) { $self->get_item_cvpartnumber($item); - my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000); + my $item_id = join('_', + 'new', + Time::HiRes::gettimeofday(), + int rand 1000000000000 + ); my $row_as_html = $self->p->render('delivery_order/tabs/_row', - ITEM => $item, - ID => $item_id, - SELF => $self, - in_out => $self->type_data->transfer, + ITEM => $item, + ID => $item_id, + SELF => $self, + in_out => $self->type_data->properties("transfer"), ); if ($::form->{insert_before_item_id}) { $self->js - ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html); + ->before( + '.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', + $row_as_html + ); } else { $self->js ->append('#row_table_id', $row_as_html); @@ -817,7 +894,9 @@ sub action_reorder_items { $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted}; my $method = $sort_keys{$::form->{order_by}}; - my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted }; + my @to_sort = + map { { old_pos => $_->position, order_by => $method->($_) } } + @{ $self->order->items_sorted }; if ($::form->{sort_dir}) { if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){ @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort; @@ -836,16 +915,6 @@ sub action_reorder_items { ->render; } -# show the popup to choose a price/discount source -sub action_price_popup { - my ($self) = @_; - - my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} }; - my $item = $self->order->items_sorted->[$idx]; - - $self->render_price_dialog($item); -} - # save the order in a session variable and redirect to the part controller sub action_create_part { my ($self) = @_; @@ -858,7 +927,9 @@ sub action_create_part { previousform => $previousform, ); - flash_later('info', t8('You are adding a new part while you are editing another document. You will be redirected to your document when saving the new part or aborting this form.')); + flash_later('info', + t8('You are adding a new part while you are editing another document. You will be redirected to your document when saving the new part or aborting this form.') + ); my @redirect_params = ( controller => 'Part', @@ -874,58 +945,80 @@ sub action_create_part { sub action_return_from_create_part { my ($self) = @_; - $self->{created_part} = SL::DB::Part->new(id => delete $::form->{new_parts_id})->load if $::form->{new_parts_id}; + $self->{created_part} = SL::DB::Part->new( + id => delete $::form->{new_parts_id} + )->load if $::form->{new_parts_id}; $::auth->restore_form_from_session(delete $::form->{previousform}); - # set item ids to new fake id, to identify them as new items - foreach my $item (@{$self->order->items_sorted}) { - $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000); - } + $self->order($self->init_order); + $self->reinit_after_new_order(); - $self->get_unalterable_data(); - $self->pre_render(); - - # trigger rendering values for second row/longdescription as hidden, - # because they are loaded only on demand. So we need to keep the values - # from the source. - $_->{render_second_row} = 1 for @{ $self->order->items_sorted }; - $_->{render_longdescription} = 1 for @{ $self->order->items_sorted }; - - $self->render( - 'delivery_order/form', - title => $self->get_title_for('edit'), - %{$self->{template_args}} - ); + if ($self->order->id) { + $self->pre_render(); + $self->render( + 'delivery_order/form', + title => $self->get_title_for('edit'), + %{$self->{template_args}} + ); + } else { + $self->action_add; + } } sub action_stock_in_out_dialog { my ($self) = @_; - my $part = SL::DB::Part->load_cached($::form->{parts_id}) or die "need parts_id"; - my $unit = SL::DB::Unit->load_cached($::form->{unit}) or die "need unit"; - my $stock = $::form->{stock}; - my $row = $::form->{row}; - my $item_id = $::form->{item_id}; - my $qty = _parse_number($::form->{qty_as_number}); + my $part = SL::DB::Part->load_cached($::form->{parts_id}) or die "need parts_id"; + my $unit = SL::DB::Unit->load_cached($::form->{unit}) or die "need unit"; + my $stock = $::form->{stock}; + my $row = $::form->{row}; + my $item_id = $::form->{item_id}; + my $qty = _parse_number($::form->{qty_as_number}); + my $row_ui_id = $::form->{row_ui_id}; + my $next_button = $::form->{next_button} eq 'true'; - my $inout = $self->type_data->transfer; + my $inout = $self->type_data->properties("transfer"); my @contents = DO->get_item_availability(parts_id => $part->id); my $stock_info = DO->unpack_stock_information(packed => $stock); $self->merge_stock_data($stock_info, \@contents, $part, $unit); + my $delivered = $self->order->delivered; $self->render("delivery_order/stock_dialog", { layout => 0 }, - WHCONTENTS => $self->order->delivered ? $stock_info : \@contents, - part => $part, - do_qty => $qty, - do_unit => $unit->unit, - delivered => $self->order->delivered, - row => $row, - item_id => $item_id, + WHCONTENTS => \@contents, + STOCK_INFO => $stock_info, + WAREHOUSES => !$delivered ? SL::DB::Manager::Warehouse->get_all(query => [ or => [ invalid => 0, invalid => undef ]], with_objects=> ["bins",]) : [], + part => $part, + do_qty => $qty, + do_unit => $unit->name, + delivered => $self->order->delivered, + row => $row, + item_id => $item_id, + in_out => $inout, + row_ui_id => $row_ui_id, + next_button => $next_button, + ); +} + +sub action_add_stock_in_line_to_dialog { + my ($self) = @_; + + my $do_qty = _parse_number($::form->{do_qty}); + my $qty_sum = $::form->{qty_sum}; + my $row_count = $::form->{row_count}; + my $part = SL::DB::Part->load_cached($::form->{parts_id}) or die "need parts_id"; + + my $row_as_html = $self->p->render('delivery_order/stock_dialog/_stock_in_new_row', + WAREHOUSES => SL::DB::Manager::Warehouse->get_all(with_objects=> ["bins",]), + PART => $part, + pos => $row_count + 1, + remaining_qty => $do_qty - $qty_sum, ); + + $self->js->append('#stock-in-out-table tbody', $row_as_html)->render(); } sub action_update_stock_information { @@ -940,7 +1033,10 @@ sub action_update_stock_information { stock_info => $yaml, stock_qty => $stock_qty, }; - $self->render(\ SL::JSON::to_json($response), { layout => 0, type => 'json', process => 0 }); + $self->render( + \ SL::JSON::to_json($response), + { layout => 0, type => 'json', process => 0 } + ); } sub merge_stock_data { @@ -950,7 +1046,9 @@ sub merge_stock_data { if (!$self->order->delivered) { for my $row (@$contents) { # row here is in parts units. stock is in item units - $row->{available_qty} = _format_number($part->unit_obj->convert_to($row->{qty}, $unit)); + $row->{available_qty} = _format_number( + $part->unit_obj->convert_to($row->{qty}, $unit) + ); for my $sinfo (@{ $stock_info }) { next if $row->{bin_id} != $sinfo->{bin_id} || @@ -987,7 +1085,8 @@ sub action_load_second_rows { $self->js_load_second_row($item, $item_id, 0); } - $self->js->run('kivi.DeliveryOrder.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback + # for lastcosts change-callback + $self->js->run('kivi.DeliveryOrder.init_row_handlers') if $self->order->is_sales; $self->js->render(); } @@ -1004,22 +1103,7 @@ sub action_update_row_from_master_data { $item->description($texts->{description}); $item->longdescription($texts->{longdescription}); - my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order); - - my $price_src; - if ($item->part->is_assortment) { - # add assortment items with price 0, as the components carry the price - $price_src = $price_source->price_from_source(""); - $price_src->price(0); - } else { - $price_src = $price_source->best_price - ? $price_source->best_price - : $price_source->price_from_source(""); - $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate; - $price_src->price(0) if !$price_source->best_price; - } - - + my ($price_src, undef) = SL::Model::Record->get_best_price_and_discount_source($self->order, $item, ignore_given => 1); $item->sellprice($price_src->price); $item->active_price_source($price_src); @@ -1041,27 +1125,27 @@ sub action_update_row_from_master_data { } sub action_transfer_stock { - my ($self) = @_; + my ($self, $default_transfer) = @_; if ($self->order->delivered) { - return $self->js->flash("error", t8('The parts for this order have already been transferred'))->render; + return $self->js->flash("error", + t8('The parts for this order have already been transferred') + )->render; } my $inout = $self->type_data->properties('transfer'); - my $errors = $self->save; - - if (@$errors) { - $self->js->flash('error', $_) for @$errors; - return $self->js->render; - } + $self->save; my $order = $self->order; # TODO move to type data my $trans_type = $inout eq 'in' - ? SL::DB::Manager::TransferType->find_by(direction => "id", description => "stock") - : SL::DB::Manager::TransferType->find_by(direction => "out", description => "shipped"); + ? SL::DB::Manager::TransferType->find_by( + direction => "in", description => "stock") + : SL::DB::Manager::TransferType->find_by( + direction => "out", description => "shipped"); + my @transfer_requests; @@ -1069,7 +1153,10 @@ sub action_transfer_stock { for my $stock (@{ $item->delivery_order_stock_entries }) { my $transfer = SL::DB::Inventory->new_from($stock); $transfer->trans_type($trans_type); + $transfer->oe_id($order->id); $transfer->qty($transfer->qty * -1) if $inout eq 'out'; + $transfer->qty($transfer->qty * 1) if $inout eq 'in'; + $transfer->comment(t8("Default transfer delivery order")) if $default_transfer; push @transfer_requests, $transfer if defined $transfer->qty && $transfer->qty != 0; }; @@ -1079,19 +1166,259 @@ sub action_transfer_stock { return $self->js->flash("error", t8("No stock to transfer"))->render; } + if ($inout eq 'out') { # check stock for enough qty + my @missing_qtys = SL::Helper::Inventory::check_stock_out_transfer_requests( + transfer_requests => \@transfer_requests, + default_transfer => $default_transfer, + ); + + if (scalar @missing_qtys) { + my $error = t8('The stock is to low: #1.', + join(". ", map { + $_->{chargenumber} && $_->{bestbefore} + ? t8( + "For #1, #2 #3 are missing of batch with chargenumber #4 and bestbefore date of #5 in bin #6", + $_->{part}->displayable_name, + $::form->format_amount(\%::myconfig, $_->{missing_qty}), + $_->{part}->unit, + $_->{chargenumber}, + DateTime->from_ymdhms($_->{bestbefore})->to_kivitendo, + $_->{bin}->full_description, + ) + : $_->{chargenumber} + ? t8( + "For #1, #2 #3 are missing of batch with chargenumber #4 in bin #5", + $_->{part}->displayable_name, + $::form->format_amount(\%::myconfig, $_->{missing_qty}), + $_->{part}->unit, + $_->{chargenumber}, + $_->{bin}->full_description, + ) + : $_->{bestbefore} + ? t8( + "For #1, #2 #3 are missing with a bestbefore date of #4 in bin #5", + $_->{part}->displayable_name, + $::form->format_amount(\%::myconfig, $_->{missing_qty}), + $_->{part}->unit, + DateTime->from_ymdhms($_->{bestbefore})->to_kivitendo, + $_->{bin}->full_description, + ) + : t8( + "For #1, #2 #3 are missing in bin #4", + $_->{part}->displayable_name, + $::form->format_amount(\%::myconfig, $_->{missing_qty}), + $_->{part}->unit, + $_->{bin}->full_description, + ) + ; + } @missing_qtys + ) + ); + return $self->js->flash("error", $error)->render; + } + } + SL::DB->client->with_transaction(sub { + $_->save for @transfer_requests; $self->order->update_attributes(delivered => 1); }); + # update qty and stock info + foreach my $item (@{$self->order->items}) { + $self->order->prepare_stock_info($item); + my $stock_info_yaml = $item->{stock_info}; + my $item_position = $item->position; + my $stock_qty = $self->calculate_stock_in_out($item); + my $unit = $item->unit; + $self->js->text("[data-position=$item_position] .data-stock-qty", "$stock_qty $unit"); + my $selector = "[data-position=$item_position] .data-stock-info"; + $self->js->val($selector, $stock_info_yaml); + } $self->js ->flash("info", t8("Stock transfered")) - ->run('kivi.ActionBar.setDisabled', '#transfer_out_action', t8('The parts for this order have already been transferred')) - ->run('kivi.ActionBar.setDisabled', '#transfer_in_action', t8('The parts for this order have already been transferred')) - ->run('kivi.ActionBar.setDisabled', '#delete_action', t8('The parts for this order have already been transferred')) - ->replaceWith('#data-status-line', delivery_order_status_line($self->order)) + ->run('kivi.ActionBar.setDisabled', '#save_action', + t8('This record has already been delivered.')) + ->run('kivi.ActionBar.setDisabled', '#save_and_close', + t8('This record has already been delivered.')) + ->run('kivi.ActionBar.setDisabled', '#transfer_out_action', + t8('The parts for this order have already been transferred')) + ->run('kivi.ActionBar.setDisabled', '#transfer_out_default_action', + t8('The parts for this order have already been transferred')) + ->run('kivi.ActionBar.setDisabled', '#transfer_in_action', + t8('The parts for this order have already been transferred')) + ->run('kivi.ActionBar.setDisabled', '#transfer_in_default_action', + t8('The parts for this order have already been transferred')) + ->run('kivi.ActionBar.setDisabled', '#delete_action', + t8('The parts for this order have already been transferred')) + ->run('kivi.ActionBar.setEnabled', '#undo_transfer_action', + t8('The parts for this order have already been transferred')) + ->html('#data-status-line', delivery_order_status_line($self->order)) ->render; +} +sub action_transfer_stock_default { + my ($self) = @_; + my $delivery_order = $self->order; + my @items = @{$delivery_order->items_sorted}; + + # get default bin if set in config + my ($default_warehouse_id, $default_bin_id); + if ($::instance_conf->get_transfer_default_use_master_default_bin) { + $default_warehouse_id = $::instance_conf->get_warehouse_id; + $default_bin_id = $::instance_conf->get_bin_id; + } + + my @transfer_requests = (); + my %parts_qty = (); + my %units_by_name = map { $_->name => $_ } @{ SL::DB::Manager::Unit->get_all }; + foreach my $item (@items) { + my $part = $item->part; + my $base_unit_factor = $units_by_name{$part->unit}->factor || 1; + my $item_unit_factor = $units_by_name{$item->unit}->factor || 1; + my $qty = $item->qty * $item_unit_factor / $base_unit_factor; + return $self->js->flash('error', t8('Cannot transfer negative entries.'))->render() if $qty < 0; + $qty = 0 if (!$::instance_conf->get_transfer_default_services && $part->is_service); + + $parts_qty{$part->id} += $qty if $qty; + push @transfer_requests, { + 'warehouse_id' => $part->warehouse_id || $default_warehouse_id, + 'bin_id' => $part->bin_id || $default_bin_id, + 'unit' => $item->unit, + 'qty' => $qty, + # added in check transfer_request out direction if possible + 'chargenumber' => undef, # $item->serialnumber, # Is not used in delivery order + 'bestbefore' => undef, # $item->bestbefore, # Is not used in delivery order + } + } + + # check transfer_requests are correctly + my %parts_errors = (); # missing_bin, missing_qty, multiple_options + my $grouped_qty_query = qq| + SELECT SUM(qty) as qty, chargenumber, bestbefore + FROM inventory + WHERE parts_id = ? AND bin_id = ? + GROUP BY chargenumber, bestbefore + |; + my $dbh = $self->order->dbh; + my $in_out_direction = $delivery_order->type_data->properties('transfer'); + for my $idx (0 .. scalar @transfer_requests - 1) { + my $transfer_request = $transfer_requests[$idx]; + next unless $transfer_request->{qty}; # empty request + my $item = $items[$idx]; + my $part_id = $item->parts_id; + my $bin_id = $transfer_request->{bin_id}; + $parts_errors{$part_id}{missing_bin} = 1 unless $bin_id; + next unless $bin_id; + if ($in_out_direction eq 'out') { + my @grouped_qty = selectall_hashref_query( + $::form, $dbh, $grouped_qty_query, $part_id, $bin_id); + + if (1 < scalar grep {$_->{qty} != 0} @grouped_qty) { + $parts_errors{$part_id}{multiple_options} = 1; + } + my $max_qty = sum0(map {$_->{qty}} @grouped_qty); + if ($max_qty < $parts_qty{$part_id}) { + $parts_errors{$part_id}{missing_qty} = $parts_qty{$part_id} - $max_qty; + $parts_errors{$part_id}{bin_id} = $bin_id; + } + + next if $parts_errors{$part_id}; + # find correct chargenumber and bestbefore + my $stock_info = first {$_->{qty} >= $transfer_request->{qty}} @grouped_qty; + $transfer_request->{chargenumber} = $stock_info->{chargenumber}; + $transfer_request->{bestbefore} = $stock_info->{bestbefore}; + } + } + + # auslagern soll immer gehen, auch wenn nicht genügend auf lager ist. + # der lagerplatz ist hier extra konfigurierbar, bspw. Lager-Korrektur mit + # Lagerplatz Lagerplatz-Korrektur + my $default_warehouse_id_ignore_onhand = $::instance_conf->get_warehouse_id_ignore_onhand; + my $default_bin_id_ignore_onhand = $::instance_conf->get_bin_id_ignore_onhand; + if ($::instance_conf->get_transfer_default_ignore_onhand && $default_bin_id_ignore_onhand) { + foreach my $part_id (keys %parts_errors) { + # entsprechende defaults holen + # falls chargenumber, bestbefore oder anzahl nicht stimmt, auf automatischen + # lagerplatz wegbuchen! + for my $idx (0 .. scalar @transfer_requests - 1) { + my $transfer_request = $transfer_requests[$idx]; + next unless $transfer_request->{qty}; # empty request + + if ($items[$idx]->parts_id eq $part_id){ + $transfer_request->{bin_id} = $default_bin_id_ignore_onhand; + $transfer_request->{warehouse_id} = $default_warehouse_id_ignore_onhand; + } + } + delete %parts_errors{$part_id}; + } + } + + # render errors + if (scalar keys %parts_errors) { + my @multiple_options = (); + foreach my $part_id (keys %parts_errors) { + my $part = SL::DB::Part->new(id => $part_id)->load(); + if ($parts_errors{$part_id}{missing_bin}){ + $self->js->error(t8('No standard bin set for #1.', $part->displayable_name)); + } + if ($parts_errors{$part_id}{missing_qty}) { + my $bin = SL::DB::Manager::Bin->find_by( + id => $parts_errors{$part_id}{bin_id} + ); + $self->js->error( + t8('There are #1 of "#2" missing from the bin #3 for transfer.', + $parts_errors{$part_id}{missing_qty}, $part->displayable_name, $bin->full_description)); + } + if ($parts_errors{$part_id}{multiple_options}){ + push @multiple_options, $part; + } + } + if (scalar @multiple_options) { + $self->js->error(t8( + "There are parts with multiple chargenumbers or bestbefore dates set. This can't be decided automatically. Pleas transfer this delivery order manually. Can't decided for #1.", + join ", ", map {$_->displayable_name} @multiple_options) + ); + } + return $self->js->render(); + } + + # assign each delivery_order_item it's stock + for my $idx (0 .. scalar @transfer_requests - 1) { + my %transfer_request = %{$transfer_requests[$idx]}; + next unless $transfer_request{qty}; # empty request + + my $item = $items[$idx]; + my @stocks = (SL::DB::DeliveryOrderItemsStock->new(%transfer_request)); + $item->delivery_order_stock_entries(@stocks); + } + + my $default_transfer = 1; + $self->action_transfer_stock($default_transfer); +} + +sub action_undo_transfers { + my ( $self ) = @_; + + SL::DB->client->with_transaction(sub { + foreach my $item (@{$self->order->orderitems}) { + foreach my $inv_item (@{ $item->delivery_order_stock_entries}) { + $inv_item->inventory->delete; + $inv_item->delete; + } + } + $self->order->update_attributes(delivered => 0); + $self->order->update_attributes(closed => 0); + }); + + flash_later('info', t8("Transfer undone")); + my @redirect_params = ( + action => 'edit', + type => $self->type, + id => $self->order->id, + ); + + $self->redirect_to(@redirect_params); } sub js_load_second_row { @@ -1155,7 +1482,8 @@ sub js_reset_order_and_item_ids_after_save { $self->js ->val('#id', $self->order->id) - ->val('#converted_from_oe_id', '') + ->val('#converted_from_record_type_ref', '') + ->val('#converted_from_record_id', '') ->val('#order_' . $self->nr_key(), $self->order->number); my $idx = 0; @@ -1163,13 +1491,17 @@ sub js_reset_order_and_item_ids_after_save { next if !$self->order->items_sorted->[$idx]->id; next if $form_item_id !~ m{^new}; $self->js - ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id) + ->val ( + '[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', + $self->order->items_sorted->[$idx]->id) ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id) - ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id); + ->attr('#item_' . $form_item_id, "id", + 'item_' . $self->order->items_sorted->[$idx]->id); } continue { $idx++; } - $self->js->val('[name="converted_from_orderitems_ids[+]"]', ''); + $self->js->val('[name="converted_from_record_item_type_refs[+]"]', ''); + $self->js->val('[name="converted_from_record_item_ids[+]"]', ''); } # @@ -1179,17 +1511,18 @@ sub js_reset_order_and_item_ids_after_save { sub init_type { my ($self) = @_; - if (none { $::form->{type} eq $_ } @{$self->valid_types}) { + my $type = $self->order->record_type; + if (none { $type eq $_ } @{$self->valid_types}) { die "Not a valid type for delivery order"; } - $self->type($::form->{type}); + $self->type($type); } sub init_cv { my ($self) = @_; - return $self->type_data->customervendor; + return $self->type_data->properties("customervendor"); } sub init_search_cvpartnumber { @@ -1217,26 +1550,24 @@ sub init_order { $_[0]->make_order; } -sub init_all_price_factors { - SL::DB::Manager::PriceFactor->get_all; -} - sub init_part_picker_classification_ids { my ($self) = @_; - return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => $self->type_data->part_classification_query) } ]; + return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all( + where => $self->type_data->part_classification_query + ) } ]; } sub check_auth { my ($self) = @_; - $::auth->assert($self->type_data->access('view') || 'DOES_NOT_EXIST'); + $::auth->assert($self->type_data->rights('view') || 'DOES_NOT_EXIST'); } sub check_auth_for_edit { my ($self) = @_; - $::auth->assert($self->type_data->access('edit') || 'DOES_NOT_EXIST'); + $::auth->assert($self->type_data->rights('edit') || 'DOES_NOT_EXIST'); } # build the selection box for contacts @@ -1261,7 +1592,11 @@ sub build_shipto_select { my ($self) = @_; select_tag('order.shipto_id', - [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ], + [ { + displayable_id => t8("No/individual shipping address"), + shipto_id => '' + }, + $self->order->{$self->cv}->shipto ], value_key => 'shipto_id', title_key => 'displayable_id', default => $self->order->shipto_id, @@ -1301,11 +1636,7 @@ sub load_order { $self->order(SL::DB::DeliveryOrder->new(id => $::form->{id})->load); - # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs. - # You need a custom shipto object to call cvars_by_config to get the cvars. - $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto; - - $self->prepare_stock_info($_) for $self->order->items; + $self->reinit_after_new_order(); return $self->order; } @@ -1323,19 +1654,38 @@ sub make_order { # be retrieved via items until the order is saved. Adding empty items to new # order here solves this problem. my $order; - $order = SL::DB::DeliveryOrder->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id}; - $order ||= SL::DB::DeliveryOrder->new(orderitems => [], currency_id => $::instance_conf->get_currency_id(), order_type => $self->type_data->validate($::form->{type})); + if ($::form->{id}) { + $order = SL::DB::DeliveryOrder->new( + id => $::form->{id} + )->load( + with => [ + 'orderitems', + 'orderitems.part', + ] + ); + } else { + $order = SL::DB::DeliveryOrder->new( + orderitems => [], + currency_id => $::instance_conf->get_currency_id(), + record_type => $::form->{type} + ); + $order = SL::Model::Record->update_after_new($order); + } - my $cv_id_method = $self->cv . '_id'; + my $cv_id_method = $order->type_data->properties('customervendor'). '_id'; if (!$::form->{id} && $::form->{$cv_id_method}) { $order->$cv_id_method($::form->{$cv_id_method}); - setup_order_from_cv($order); + $order = SL::Model::Record->update_after_customer_vendor_change($order); } - my $form_orderitems = delete $::form->{order}->{orderitems}; + # don't assign hashes as objects + my $form_orderitems = delete $::form->{order}->{orderitems}; $order->assign_attributes(%{$::form->{order}}); + # restore form values + $::form->{order}->{orderitems} = $form_orderitems; + $self->setup_custom_shipto_from_form($order, $::form); # remove deleted items @@ -1357,8 +1707,6 @@ sub make_order { $pos++; } - $self->prepare_stock_info($_) for $order->items, @items; - $order->add_items(grep {!$_->id} @items); return $order; @@ -1430,36 +1778,11 @@ sub new_item { $item->assign_attributes(%$attr); - my $part = SL::DB::Part->new(id => $attr->{parts_id})->load; - my $price_source = SL::PriceSource->new(record_item => $item, record => $record); - + my $part = SL::DB::Part->new(id => $attr->{parts_id})->load; + $item->qty(1.0) if !$item->qty; $item->unit($part->unit) if !$item->unit; - my $price_src; - if ( $part->is_assortment ) { - # add assortment items with price 0, as the components carry the price - $price_src = $price_source->price_from_source(""); - $price_src->price(0); - } elsif (defined $item->sellprice) { - $price_src = $price_source->price_from_source(""); - $price_src->price($item->sellprice); - } else { - $price_src = $price_source->best_price - ? $price_source->best_price - : $price_source->price_from_source(""); - $price_src->price(0) if !$price_source->best_price; - } - - my $discount_src; - if (defined $item->discount) { - $discount_src = $price_source->discount_from_source(""); - $discount_src->discount($item->discount); - } else { - $discount_src = $price_source->best_discount - ? $price_source->best_discount - : $price_source->discount_from_source(""); - $discount_src->discount(0) if !$price_source->best_discount; - } + my ($price_src, $discount_src) = SL::Model::Record->get_best_price_and_discount_source($record, $item, ignore_given => 0); my %new_attr; $new_attr{part} = $part; @@ -1479,44 +1802,17 @@ sub new_item { # saved. Adding empty custom_variables to new orderitem here solves this problem. $new_attr{custom_variables} = []; - my $texts = get_part_texts($part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription}); + my $texts = get_part_texts( + $part, $record->language_id, + description => $new_attr{description}, + longdescription => $new_attr{longdescription} + ); $item->assign_attributes(%new_attr, %{ $texts }); return $item; } -sub prepare_stock_info { - my ($self, $item) = @_; - - $item->{stock_info} = SL::YAML::Dump([ - map +{ - delivery_order_items_stock_id => $_->id, - qty => $_->qty, - warehouse_id => $_->warehouse_id, - bin_id => $_->bin_id, - chargenumber => $_->chargenumber, - unit => $_->unit, - }, $item->delivery_order_stock_entries - ]); -} - -sub setup_order_from_cv { - my ($order) = @_; - - $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id)); - - $order->intnotes($order->customervendor->notes); - - if ($order->is_sales) { - $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id); - $order->taxincluded(defined($order->customer->taxincluded_checked) - ? $order->customer->taxincluded_checked - : $::myconfig{taxincluded_checked}); - } - -} - # setup custom shipto from form # # The dialog returns form variables starting with 'shipto' and cvars starting @@ -1531,10 +1827,22 @@ sub setup_custom_shipto_from_form { if ($order->shipto) { $self->is_custom_shipto_to_delete(1); } else { - my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])); - - my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form}; - my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form}; + my $custom_shipto = + $order->custom_shipto + || $order->custom_shipto( + SL::DB::Shipto->new(module => 'DO', custom_variables => []) + ); + + my $shipto_cvars = { + map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} + grep { m{^shiptocvar_} } + keys %$form + }; + my $shipto_attrs = { + map { $_ => delete $form->{$_}} + grep { m{^shipto} } + keys %$form + }; $custom_shipto->assign_attributes(%$shipto_attrs); $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars; @@ -1557,180 +1865,72 @@ sub get_unalterable_data { } } -# delete the order -# -# And remove related files in the spool directory -sub delete { - my ($self) = @_; - - my $errors = []; - my $db = $self->order->db; - - $db->with_transaction( - sub { - my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) }; - $self->order->delete; - my $spool = $::lx_office_conf{paths}->{spool}; - unlink map { "$spool/$_" } @spoolfiles if $spool; - - $self->save_history('DELETED'); - - 1; - }) || push(@{$errors}, $db->error); - - return $errors; -} - # save the order # # And delete items that are deleted in the form. sub save { my ($self) = @_; - my $errors = []; - my $db = $self->order->db; - - $db->with_transaction(sub { - # delete custom shipto if it is to be deleted or if it is empty - if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) { - $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id; - $self->order->custom_shipto(undef); - } - - SL::DB::DeliveryOrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []}; - $self->order->save(cascade => 1); - - # link records - if ($::form->{converted_from_oe_id}) { - my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id}; - foreach my $converted_from_oe_id (@converted_from_oe_ids) { - my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load; - $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/ && $self->order->is_type(PURCHASE_DELIVERY_ORDER_TYPE); - $src->link_to_record($self->order); - } - if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) { - my $idx = 0; - foreach (@{ $self->order->items_sorted }) { - my $from_id = $::form->{converted_from_orderitems_ids}->[$idx]; - next if !$from_id; - SL::DB::RecordLink->new(from_table => 'orderitems', - from_id => $from_id, - to_table => 'orderitems', - to_id => $_->id - )->save; - $idx++; - } - } - } + set_record_link_conversions($self->order, + delete $::form->{RECORD_TYPE_REF()} + => delete $::form->{RECORD_ID()}, + delete $::form->{RECORD_ITEM_TYPE_REF()} + => delete $::form->{RECORD_ITEM_ID()}, + ); - $self->save_history('SAVED'); + my $items_to_delete = scalar @{ $self->item_ids_to_delete || [] } + ? SL::DB::Manager::DeliveryOrderItem->get_all(where => [id => $self->item_ids_to_delete]) + : undef; + + SL::Model::Record->save($self->order, + with_validity_token => { + scope => SL::DB::ValidityToken::SCOPE_DELIVERY_ORDER_SAVE(), + token => $::form->{form_validity_token} + }, + delete_custom_shipto => $self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty), + items_to_delete => $items_to_delete, + ); - 1; - }) || push(@{$errors}, $db->error); + if ($::form->{email_journal_id}) { + my $email_journal = SL::DB::EmailJournal->new( + id => delete $::form->{email_journal_id} + )->load; + $email_journal->link_to_record_with_attachment( + $self->order, + delete $::form->{email_attachment_id} + ); + } - return $errors; + delete $::form->{form_validity_token}; } -sub workflow_sales_or_request_for_quotation { +sub reinit_after_new_order { my ($self) = @_; - # always save - my $errors = $self->save(); - - if (scalar @{ $errors }) { - $self->js->flash('error', $_) for @{ $errors }; - return $self->js->render(); - } - - my $destination_type = $self->type_data->workflow("to_quotation_type"); - - $self->order(SL::DB::DeliveryOrder->new_from($self->order, destination_type => $destination_type)); - $self->{converted_from_oe_id} = delete $::form->{id}; - - # set item ids to new fake id, to identify them as new items - foreach my $item (@{$self->order->items_sorted}) { - $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000); - } - # change form type - $::form->{type} = $destination_type; + $::form->{type} = $self->order->type; $self->type($self->init_type); - $self->cv ($self->init_cv); + $self->type_data($self->init_type_data); + $self->cv($self->init_cv); $self->check_auth; - $self->get_unalterable_data(); - $self->pre_render(); - - # trigger rendering values for second row as hidden, because they - # are loaded only on demand. So we need to keep the values from the - # source. - $_->{render_second_row} = 1 for @{ $self->order->items_sorted }; - - $self->render( - 'delivery_order/form', - title => $self->get_title_for('edit'), - %{$self->{template_args}} - ); -} - -sub workflow_sales_or_purchase_order { - my ($self) = @_; - - # always save - my $errors = $self->save(); + $self->setup_custom_shipto_from_form($self->order, $::form); - if (scalar @{ $errors }) { - $self->js->flash('error', $_) foreach @{ $errors }; - return $self->js->render(); - } - - my $destination_type = $self->type_data->workflow("to_order_type"); - - # check for direct delivery - # copy shipto in custom shipto (custom shipto will be copied by new_from() in case) - my $custom_shipto; - if ($self->type_data->workflow("to_order_copy_shipto") && $::form->{use_shipto} && $self->order->shipto) { - $custom_shipto = $self->order->shipto->clone('SL::DB::DeliveryOrder'); - } - - $self->order(SL::DB::DeliveryOrder->new_from($self->order, destination_type => $destination_type)); - $self->{converted_from_oe_id} = delete $::form->{id}; - - # set item ids to new fake id, to identify them as new items foreach my $item (@{$self->order->items_sorted}) { + # set item ids to new fake id, to identify them as new items $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000); - } - if ($self->type_data->workflow("to_order_copy_shipto")) { - if ($::form->{use_shipto}) { - $self->order->custom_shipto($custom_shipto) if $custom_shipto; - } else { - # remove any custom shipto if not wanted - $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])); - } + # trigger rendering values for second row as hidden, because they + # are loaded only on demand. So we need to keep the values from the + # source. + $item->{render_second_row} = 1; } - # change form type - $::form->{type} = $destination_type; - $self->type($self->init_type); - $self->cv ($self->init_cv); - $self->check_auth; - + $self->order->prepare_stock_info($_) for $self->order->items; $self->get_unalterable_data(); - $self->pre_render(); - - # trigger rendering values for second row as hidden, because they - # are loaded only on demand. So we need to keep the values from the - # source. - $_->{render_second_row} = 1 for @{ $self->order->items_sorted }; - - $self->render( - 'delivery_order/form', - title => $self->get_title_for('edit'), - %{$self->{template_args}} - ); } + sub pre_render { my ($self) = @_; @@ -1770,7 +1970,7 @@ sub pre_render { $item->active_discount_source($price_source->discount_from_source($item->active_discount_source)); } - if ($self->order->${\ $self->type_data->nr_key } && $::instance_conf->get_webdav) { + if ($self->order->${\ $self->type_data->properties("nr_key") } && $::instance_conf->get_webdav) { my $webdav = SL::Webdav->new( type => $self->type, number => $self->order->number, @@ -1782,13 +1982,15 @@ sub pre_render { } } @all_objects; } - $self->{template_args}{in_out} = $self->type_data->transfer; + $self->{template_args}{in_out} = $self->type_data->properties("transfer"); $self->{template_args}{longdescription_dialog_size_percentage} = SL::Helper::UserPreferences::DisplayPreferences->new()->get_longdescription_dialog_size_percentage(); $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted}; - $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.DeliveryOrder kivi.File ckeditor/ckeditor ckeditor/adapters/jquery - calculate_qty kivi.Validator follow_up show_history); + $::request->{layout}->use_javascript("${_}.js") for qw( + kivi.SalesPurchase kivi.DeliveryOrder kivi.File calculate_qty kivi.Validator + follow_up show_history + ); $self->setup_edit_action_bar; } @@ -1796,23 +1998,64 @@ sub setup_edit_action_bar { my ($self, %params) = @_; my $deletion_allowed = $self->type_data->show_menu("delete"); - my $may_edit_create = $::auth->assert($self->type_data->access('edit') || 'DOES_NOT_EXIST', 1); + my $may_edit_create = $::auth->assert( + $self->type_data->rights('edit') || 'DOES_NOT_EXIST', 1 + ); + + my $confirmation_on_workflow = $self->order->delivered ? undef + : ( $self->order->is_sales && $::instance_conf->get_sales_delivery_order_check_stocked) ? t8('This record has not been stocked out. Proceed?') + : (!$self->order->is_sales && $::instance_conf->get_purchase_delivery_order_check_stocked) ? t8('This record has not been stocked in. Proceed?') + : undef; for my $bar ($::request->layout->get('actionbar')) { $bar->add( combobox => [ action => [ t8('Save'), - call => [ 'kivi.DeliveryOrder.save', 'save', $::instance_conf->get_order_warn_duplicate_parts, - $::instance_conf->get_order_warn_no_deliverydate, - ], - disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, + id => 'save_action', + call => [ 'kivi.DeliveryOrder.save', { + action => 'save', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_order_warn_no_deliverydate, + }], + disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') + : $self->order->delivered ? t8('This record has already been delivered.') + : undef, + ], + action => [ + t8('Save and Close'), + id => 'save_and_close', + call => [ 'kivi.DeliveryOrder.save', { + action => 'save', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_order_warn_no_deliverydate, + form_params => [ + { name => 'back_to_caller', value => 1 }, + ], + }], + disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') + : $self->order->delivered ? t8('This record has already been delivered.') + : undef, + ], + action => [ + t8('Mark as closed'), + id => 'close_order', + call => [ 'kivi.DeliveryOrder.close_order' ], + confirm => t8('This will remove the delivery order from showing as open even if contents are not delivered. Proceed?'), + disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') + : !$self->order->id ? t8('This object has not been saved yet.') + : $self->order->closed ? t8('This record has already been closed.') + : undef, ], action => [ t8('Save as new'), - call => [ 'kivi.DeliveryOrder.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ], + call => [ 'kivi.DeliveryOrder.save', { + action => 'save_as_new', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + }], disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : $self->type eq 'supplier_delivery_order' ? t8('Need a workflow for Supplier Delivery Order') + : $self->type eq 'rma_delivery_order' ? t8('Need a workflow for RMA Delivery Order.') : !$self->order->id ? t8('This object has not been saved yet.') : undef, ], @@ -1823,48 +2066,33 @@ sub setup_edit_action_bar { t8('Workflow'), ], action => [ - t8('Save and Quotation'), - submit => [ '#order_form', { action => "DeliveryOrder/sales_quotation" } ], - only_if => $self->type_data->show_menu("save_and_quotation"), - disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, - ], - action => [ - t8('Save and RFQ'), - submit => [ '#order_form', { action => "DeliveryOrder/request_for_quotation" } ], - only_if => $self->type_data->show_menu("save_and_rfq"), - disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, - ], - action => [ - t8('Save and Sales Order'), - submit => [ '#order_form', { action => "DeliveryOrder/sales_order" } ], - only_if => $self->type_data->show_menu("save_and_sales_order"), - disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, - ], - action => [ - t8('Save and Purchase Order'), - call => [ 'kivi.DeliveryOrder.purchase_order_check_for_direct_delivery' ], - only_if => $self->type_data->show_menu("save_and_purchase_order"), - disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, - ], - action => [ - t8('Save and Delivery Order'), - call => [ 'kivi.DeliveryOrder.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts, - $::instance_conf->get_order_warn_no_deliverydate, - ], - only_if => $self->type_data->show_menu("save_and_delivery_order"), - disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, - ], - action => [ - t8('Save and Invoice'), - call => [ 'kivi.DeliveryOrder.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ], - only_if => $self->type_data->show_menu("save_and_invoice"), - disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, + t8('Create Invoice'), + call => [ 'kivi.DeliveryOrder.save', { + action => 'workflow_invoice', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + }], + only_if => $self->type_data->show_menu("workflow_invoice"), + disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') + : !$self->order->id ? t8('This object has not been saved yet.') + : undef, + confirm => $confirmation_on_workflow, ], action => [ - t8('Save and AP Transaction'), - call => [ 'kivi.DeliveryOrder.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ], - only_if => $self->type_data->show_menu("save_and_ap_transaction"), - disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, + t8('Create Reclamation'), + call => [ 'kivi.DeliveryOrder.save', { + action => 'workflow_new_record', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + form_params => [ + { name => 'to_type', + value => $self->order->is_sales ? SALES_RECLAMATION_TYPE() + : PURCHASE_RECLAMATION_TYPE() }, + ], + }], + only_if => $self->type_data->show_menu('workflow_reclamation'), + disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') + : !$self->order->id ? t8('This object has not been saved yet.') + : undef, + confirm => $confirmation_on_workflow, ], ], # end of combobox "Workflow" @@ -1875,24 +2103,29 @@ sub setup_edit_action_bar { ], action => [ t8('Save and preview PDF'), - call => [ 'kivi.DeliveryOrder.save', 'preview_pdf', $::instance_conf->get_order_warn_duplicate_parts, - $::instance_conf->get_order_warn_no_deliverydate, - ], + call => [ 'kivi.DeliveryOrder.save', { + action => 'preview_pdf', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_order_warn_no_deliverydate, + }], disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, ], action => [ t8('Save and print'), - call => [ 'kivi.DeliveryOrder.show_print_options', $::instance_conf->get_order_warn_duplicate_parts, - $::instance_conf->get_order_warn_no_deliverydate, + call => [ 'kivi.DeliveryOrder.show_print_options', { + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_order_warn_no_deliverydate }, ], disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, ], action => [ t8('Save and E-mail'), id => 'save_and_email_action', - call => [ 'kivi.DeliveryOrder.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts, - $::instance_conf->get_order_warn_no_deliverydate, - ], + call => [ 'kivi.DeliveryOrder.save', { + action => 'save_and_show_email_dialog', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_order_warn_no_deliverydate, + }], disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : !$self->order->id ? t8('This object has not been saved yet.') : undef, @@ -1923,7 +2156,22 @@ sub setup_edit_action_bar { action => [ t8('Transfer out'), id => 'transfer_out_action', - call => [ 'kivi.DeliveryOrder.save', 'transfer_stock' ], + call => [ 'kivi.DeliveryOrder.save', { + action => 'transfer_stock', + }], + disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') + : !$self->order->id ? t8('This object has not been saved yet.') + : $self->order->delivered ? t8('The parts for this order have already been transferred') + : undef, + only_if => $self->type_data->properties('transfer') eq 'out', + confirm => t8('Do you really want to transfer the stock and set this order to delivered?'), + ], + action => [ + t8('Transfer out via default'), + id => 'transfer_out_default_action', + call => [ 'kivi.DeliveryOrder.save', { + action => 'transfer_stock_default', + }], disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : !$self->order->id ? t8('This object has not been saved yet.') : $self->order->delivered ? t8('The parts for this order have already been transferred') @@ -1934,7 +2182,22 @@ sub setup_edit_action_bar { action => [ t8('Transfer in'), id => 'transfer_in_action', - call => [ 'kivi.DeliveryOrder.save', 'transfer_stock' ], + call => [ 'kivi.DeliveryOrder.save', { + action => 'transfer_stock', + }], + disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') + : !$self->order->id ? t8('This object has not been saved yet.') + : $self->order->delivered ? t8('The parts for this order have already been transferred') + : undef, + only_if => $self->type_data->properties('transfer') eq 'in', + confirm => t8('Do you really want to transfer the stock and set this order to delivered?'), + ], + action => [ + t8('Transfer in via default'), + id => 'transfer_in_default_action', + call => [ 'kivi.DeliveryOrder.save', { + action => 'transfer_stock_default', + }], disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : !$self->order->id ? t8('This object has not been saved yet.') : $self->order->delivered ? t8('The parts for this order have already been transferred') @@ -1942,6 +2205,18 @@ sub setup_edit_action_bar { only_if => $self->type_data->properties('transfer') eq 'in', confirm => t8('Do you really want to transfer the stock and set this order to delivered?'), ], + action => [ + t8('Undo Transfer'), + id => 'undo_transfer_action', + call => [ 'kivi.DeliveryOrder.save', { + action => 'undo_transfers', + }], + disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') + : !$self->order->id ? t8('This object has not been saved yet.') + : undef, + disabled => !$self->order->delivered, + confirm => t8('Do you really want undo transfers the stock and set this order to undelivered?'), + ], ], combobox => [ @@ -1998,7 +2273,10 @@ sub generate_pdf { ); if (!defined $template_file) { - push @errors, $::locale->text('Cannot find matching template for this print request. Please contact your template maintainer. I tried these: #1.', join ', ', map { "'$_'"} @template_files); + push @errors, $::locale->text( + 'Cannot find matching template for this print request. Please contact your template maintainer. I tried these: #1.', + join ', ', map { "'$_'"} @template_files + ); } return @errors if scalar @errors; @@ -2072,10 +2350,14 @@ sub get_item_cvpartnumber { return if !$self->order->customervendor; if ($self->cv eq 'vendor') { - my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels}; + my @mms = + grep { $_->make eq $self->order->customervendor->id } + @{$item->part->makemodels}; $item->{cvpartnumber} = $mms[0]->model if scalar @mms; } elsif ($self->cv eq 'customer') { - my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices}; + my @cps = + grep { $_->customer_id eq $self->order->customervendor->id } + @{$item->part->customerprices}; $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps; } } @@ -2105,22 +2387,7 @@ sub get_part_texts { } sub nr_key { - return $_[0]->type_data->nr_key; -} - -sub save_and_redirect_to { - my ($self, %params) = @_; - - my $errors = $self->save(); - - if (scalar @{ $errors }) { - $self->js->flash('error', $_) foreach @{ $errors }; - return $self->js->render(); - } - - flash_later('info', $self->type_data->text("saved")); - - $self->redirect_to(%params, id => $self->order->id); + return $_[0]->type_data->properties("nr_key"); } sub save_history { @@ -2139,7 +2406,7 @@ sub save_history { } sub store_pdf_to_webdav_and_filemanagement { - my($order, $content, $filename) = @_; + my($order, $content, $filename, $variant) = @_; my @errors; @@ -2168,7 +2435,8 @@ sub store_pdf_to_webdav_and_filemanagement { source => 'created', file_type => 'document', file_name => $filename, - file_contents => $content); + file_contents => $content, + print_variant => $variant); 1; } or do { push @errors, t8('Storing PDF in storage backend failed: #1', $@); @@ -2209,7 +2477,8 @@ sub calculate_stock_in_out { } sub init_type_data { - SL::Controller::DeliveryOrder::TypeData->new($_[0]); + my ($self) = @_; + SL::DB::Helper::TypeDataProxy->new('SL::DB::DeliveryOrder', $self->order->record_type); } sub init_valid_types { @@ -2407,7 +2676,7 @@ editor or on text processing application). =item * -A warning when leaving the page without saveing unchanged inputs. +A warning when leaving the page without saving unchanged inputs. =back diff --git a/SL/Controller/DeliveryOrder/TypeData.pm b/SL/Controller/DeliveryOrder/TypeData.pm deleted file mode 100644 index 0650e08b3..000000000 --- a/SL/Controller/DeliveryOrder/TypeData.pm +++ /dev/null @@ -1,101 +0,0 @@ -package SL::Controller::DeliveryOrder::TypeData; - -use strict; -use Exporter qw(import); -use Scalar::Util qw(weaken); -use SL::Locale::String qw(t8); -use SL::DB::DeliveryOrder::TypeData qw(:types :subs); - -my @export_types = qw(SALES_DELIVERY_ORDER_TYPE PURCHASE_DELIVERY_ORDER_TYPE SUPPLIER_DELIVERY_ORDER_TYPE RMA_DELIVERY_ORDER_TYPE); - -our @EXPORT_OK = (@export_types); -our %EXPORT_TAGS = (types => \@export_types); - -use Rose::Object::MakeMethods::Generic scalar => [ qw(c) ]; - -sub new { - my ($class, $controller) = @_; - my $o = bless {}, $class; - - if ($controller) { - $o->c($controller); - weaken($o->{c}); - } - - return $o; -} - -sub validate { - my ($self, $string) = @_; - validate_type($string); -} - -sub text { - my ($self, $string) = @_; - get3($self->c->type, "text", $string); -} - -sub show_menu { - my ($self, $string) = @_; - get3($self->c->type, "show_menu", $string); -} - -sub workflow { - my ($self, $string) = @_; - get3($self->c->type, "workflow", $string); -} - -sub properties { - my ($self, $string) = @_; - get3($self->c->type, "properties", $string); -} - -sub access { - my ($self, $string) = @_; - get3($_[0]->c->type, "rights", $string); -} - -sub is_quotation { - get3($_[0]->c->type, "properties", "is_quotation"); -} - -sub customervendor { - get3($_[0]->c->type, "properties", "customervendor"); -} - -sub is_customer { - get3($_[0]->c->type, "properties", "is_customer"); -} - -sub nr_key { - get3($_[0]->c->type, "properties", "nr_key"); -} - -sub transfer { - get3($_[0]->c->type, "properties", "transfer"); -} - -sub part_classification_query { - my ($self, $string) = @_; - get($self->c->type, "part_classification_query"); -} - -sub set_reqdate_by_type { - my ($self) = @_; - - if (!$self->c->order->reqdate) { - $self->c->order->reqdate(DateTime->today_local->next_workday(extra_days => 1)); - } -} - -sub get_reqdate_by_type { - my ($self, $reqdate, $saved_reqdate) = @_; - - if ($reqdate == $saved_reqdate) { - return DateTime->today_local->next_workday(extra_days => 1); - } else { - return $reqdate; - } -} - -1; diff --git a/SL/Controller/DeliveryPlan.pm b/SL/Controller/DeliveryPlan.pm index 6d7aaa908..0a120c6ec 100644 --- a/SL/Controller/DeliveryPlan.pm +++ b/SL/Controller/DeliveryPlan.pm @@ -5,6 +5,7 @@ use parent qw(SL::Controller::Base); use Clone qw(clone); use SL::DB::OrderItem; +use SL::DB::Order::TypeData qw(:types); use SL::DB::Business; use SL::Controller::Helper::GetModels; use SL::Controller::Helper::ReportGenerator; @@ -49,9 +50,10 @@ sub action_list { sub prepare_report { my ($self) = @_; - my $vc = $self->vc; - my $report = SL::ReportGenerator->new(\%::myconfig, $::form); - $self->{report} = $report; + my $vc = $self->vc; + my $report = SL::ReportGenerator->new(\%::myconfig, $::form); + $report->{title} = t8('Delivery Plan'); + $self->{report} = $report; my @columns = qw(reqdate customer vendor ordnumber partnumber description qty shipped_qty not_shipped_qty); @@ -95,6 +97,7 @@ sub prepare_report { $self->models->disable_plugin('paginated') if $report->{options}{output_format} =~ /^(pdf|csv)$/i; $self->models->finalize; # for filter laundering $self->models->set_report_generator_sort_options(report => $report, sortable_columns => \@sortable); + $self->{assembly_item_number} = $::form->{assembly_item_number} ? $::form->{assembly_item_number} : undef; $report->set_options( raw_top_info_text => $self->render('delivery_plan/report_top', { output => 0 }), raw_bottom_info_text => $self->render('delivery_plan/report_bottom', { output => 0 }, models => $self->models), @@ -115,7 +118,7 @@ sub calc_qtys { sub make_filter_summary { my ($self) = @_; my $vc = $self->vc; - my ($business, $employee, $department); + my ($business, $employee, $department, $assembly_item_number); my $filter = $::form->{filter} || {}; my @filter_strings; @@ -123,6 +126,7 @@ sub make_filter_summary { $business = SL::DB::Business->new(id => $filter->{order}{customer}{"business_id"})->load->description if $filter->{order}{customer}{"business_id"}; $employee = SL::DB::Employee->new(id => $filter->{order}{employee_id})->load->name if $filter->{order}{employee_id}; $department = SL::DB::Department->new(id => $filter->{order}{department_id})->load->description if $filter->{order}{department_id}; + $assembly_item_number = $::form->{assembly_item_number}; my @filters = ( [ $filter->{order}{"ordnumber:substr::ilike"}, $::locale->text('Number') ], @@ -139,6 +143,7 @@ sub make_filter_summary { [ $business, $::locale->text('Customer type') ], [ $department, $::locale->text('Department') ], [ $employee, $::locale->text('Employee') ], + [ $assembly_item_number, $::locale->text('Assembly Item Number') ], ); my %flags = ( @@ -160,14 +165,13 @@ sub make_filter_summary { sub delivery_plan_query_linked_items { my ($self) = @_; - my $vc = $self->vc; + my $record_type = ($self->vc eq 'customer' ? SALES_ORDER_TYPE() : PURCHASE_ORDER_TYPE()); my $employee_id = SL::DB::Manager::Employee->current->id; my $oe_owner = $_[0]->all_edit_right ? '' : " oe.employee_id = $employee_id AND"; [ - "order.${vc}_id" => { gt => 0 }, + record_type => $record_type, 'order.closed' => 0, - or => [ 'order.quotation' => 0, 'order.quotation' => undef ], # filter by shipped_qty < qty, read from innermost to outermost 'id' => [ \" @@ -176,8 +180,7 @@ sub delivery_plan_query_linked_items { FROM orderitems oi, oe, record_links rl, delivery_order_items doi WHERE oe.id = oi.trans_id AND - oe.${vc}_id IS NOT NULL AND - (oe.quotation = 'f' OR oe.quotation IS NULL) AND + oe.record_type = '$record_type' AND NOT oe.closed AND $oe_owner doi.id = rl.to_id AND @@ -195,8 +198,7 @@ sub delivery_plan_query_linked_items { SELECT oi.id FROM orderitems oi, oe WHERE oe.id = oi.trans_id AND - oe.${vc}_id IS NOT NULL AND - (oe.quotation = 'f' OR oe.quotation IS NULL) AND + oe.record_type = '$record_type' AND NOT oe.closed AND $oe_owner NOT EXISTS ( @@ -218,6 +220,28 @@ sub init_models { my $query = $self->delivery_plan_query_linked_items; + if ($::form->{assembly_item_number}) { + + my $assembly_parts = SL::DB::Manager::Part->get_all(where => [ partnumber => { ilike => '%' . $::form->{assembly_item_number} . '%' } ]); + + my @assemblies; + + foreach my $assembly_part (@{ $assembly_parts }) { + push @assemblies, SL::DB::Manager::Assembly->get_all(where => [parts_id =>$assembly_part->id]); + } + + if (scalar @assemblies > 0) { + my %assembly_ids; + foreach my $list (@assemblies) { + foreach my $assembly (@{ $list }) { + $assembly_ids{$assembly->id} = 1; + } + } + my @assembly_ids_array = (keys %assembly_ids); + $::form->{filter}{part}{id} = { or => [ @assembly_ids_array ] }; + } + } + SL::Controller::Helper::GetModels->new( controller => $self, model => 'OrderItem', @@ -258,14 +282,8 @@ sub link_to { if ($object->isa('SL::DB::Order')) { my $type = $object->type; - my $vc = $object->is_sales ? 'customer' : 'vendor'; my $id = $object->id; - - if ($::instance_conf->get_feature_experimental_order) { - return "controller.pl?action=Order/$action&type=$type&id=$id"; - } else { - return "oe.pl?action=$action&type=$type&vc=$vc&id=$id"; - } + return "controller.pl?action=Order/$action&type=$type&id=$id"; } if ($object->isa('SL::DB::Part')) { my $id = $object->id; diff --git a/SL/Controller/DeliveryTerm.pm b/SL/Controller/DeliveryTerm.pm index d566b5c9f..86c0ed19f 100644 --- a/SL/Controller/DeliveryTerm.pm +++ b/SL/Controller/DeliveryTerm.pm @@ -4,8 +4,10 @@ use strict; use parent qw(SL::Controller::Base); +use SL::DB::Customer; use SL::DB::DeliveryTerm; use SL::DB::Language; +use SL::DB::Vendor; use SL::Helper::Flash; use SL::Locale::String qw(t8); @@ -110,6 +112,15 @@ sub create_or_update { $self->{delivery_term}->save_attribute_translation('description_long', $language, $::form->{"translation_" . $language->id}); } + if ($::form->{remove_customer_vendor_delivery_terms}) { + foreach my $class (qw(Customer Vendor)) { + "SL::DB::Manager::${class}"->update_all( + set => { delivery_term_id => undef }, + where => [ delivery_term_id => $self->{delivery_term}->id ], + ); + } + } + flash_later('info', $is_new ? $::locale->text('The delivery term has been created.') : $::locale->text('The delivery term has been saved.')); $self->redirect_to(action => 'list'); } diff --git a/SL/Controller/DeliveryValueReport.pm b/SL/Controller/DeliveryValueReport.pm index ae2032fcc..f5608336e 100644 --- a/SL/Controller/DeliveryValueReport.pm +++ b/SL/Controller/DeliveryValueReport.pm @@ -5,6 +5,7 @@ use parent qw(SL::Controller::Base); use Clone qw(clone); use SL::DB::OrderItem; +use SL::DB::Order::TypeData qw(:types); use SL::DB::Business; use SL::Controller::Helper::GetModels; use SL::Controller::Helper::ReportGenerator; @@ -63,10 +64,11 @@ sub action_list { sub prepare_report { my ($self) = @_; - my $vc = $self->vc; - my $report = SL::ReportGenerator->new(\%::myconfig, $::form); - my $csv_option = $::form->{report_generator_output_format}; - $self->{report} = $report; + my $vc = $self->vc; + my $report = SL::ReportGenerator->new(\%::myconfig, $::form); + my $csv_option = $::form->{report_generator_output_format}; + $report->{title} = t8('Delivery Value Report'); + $self->{report} = $report; my @columns = qw(reqdate customer vendor ordnumber partnumber description unit qty netto_qty not_shipped_qty netto_not_shipped_qty shipped_qty netto_shipped_qty delivered_qty @@ -192,6 +194,7 @@ sub make_filter_summary { sub init_models { my ($self) = @_; my $vc = $self->vc; + my $record_type = ($vc eq 'customer' ? SALES_ORDER_TYPE() : PURCHASE_ORDER_TYPE()); SL::Controller::Helper::GetModels->new( controller => $self, model => 'OrderItem', @@ -204,7 +207,7 @@ sub init_models { }, # show only open (sales|purchase) orders query => [ 'order.closed' => '0', "order.${vc}_id" => { gt => 0 }, - 'order.quotation' => 0 ], + 'order.record_type' => $record_type ], with_objects => [ 'order', "order.$vc", 'part' ], additional_url_params => { vc => $vc}, ) @@ -232,14 +235,8 @@ sub link_to { if ($object->isa('SL::DB::Order')) { my $type = $object->type; - my $vc = $object->is_sales ? 'customer' : 'vendor'; my $id = $object->id; - - if ($::instance_conf->get_feature_experimental_order) { - return "controller.pl?action=Order/$action&type=$type&id=$id"; - } else { - return "oe.pl?action=$action&type=$type&vc=$vc&id=$id"; - } + return "controller.pl?action=Order/$action&type=$type&id=$id"; } if ($object->isa('SL::DB::Part')) { my $id = $object->id; diff --git a/SL/Controller/DispositionManager.pm b/SL/Controller/DispositionManager.pm new file mode 100644 index 000000000..0e70c5069 --- /dev/null +++ b/SL/Controller/DispositionManager.pm @@ -0,0 +1,426 @@ +package SL::Controller::DispositionManager; + +use strict; + +use parent qw(SL::Controller::Base); + +use SL::Controller::Helper::GetModels; +use SL::Controller::Helper::ReportGenerator; +use SL::DB::Part; +use SL::DB::PurchaseBasketItem; +use SL::DB::Order; +use SL::DB::OrderItem; +use SL::DB::Vendor; +use SL::PriceSource; +use SL::Locale::String qw(t8); +use SL::Helper::Flash qw(flash flash_later); +use SL::DBUtils; + +use Data::Dumper; + +use Rose::Object::MakeMethods::Generic ( + 'scalar --get_set_init' => [ qw(models) ], +); + +sub action_list_parts { + my ($self) = @_; + $self->prepare_report(t8('Reorder Level List'), $::form->{noshow} ? 1 : 0 ); + + my $objects = $::form->{noshow} ? [] : $self->models->get; + + $self->_setup_list_action_bar; + $self->report_generator_list_objects( + report => $self->{report}, objects => $objects); +} + +sub prepare_report { + my ($self, $title, $noshow ) = @_; + + my $report = SL::ReportGenerator->new(\%::myconfig, $::form); + $self->{report} = $report; + + my @columns = qw( + partnumber description available onhand rop ordered + ); + my @visible = qw( + partnumber description available onhand rop ordered + ); + my @sortable = qw(partnumber description); + + my %column_defs = ( + partnumber => { + sub => sub { $_[0]->partnumber }, + text => t8('Part Number'), + obj_link => sub { $_[0]->presenter->link_to }, + }, + description => { + sub => sub { $_[0]->description }, + text => t8('Part Description'), + obj_link => sub { $_[0]->presenter->link_to }, + }, + available => { + sub => sub { $::form->format_amount(\%::myconfig,$_[0]->onhandqty,2); }, + text => t8('Available Stock'), + }, + onhand => { + sub => sub { $::form->format_amount(\%::myconfig,$_[0]->stockqty,2); }, + text => t8('Total Stock'), + }, + rop => { + sub => sub { $::form->format_amount(\%::myconfig,$_[0]->rop,2); }, + text => t8('Rop'), + }, + ordered => { + sub => sub { $::form->format_amount( + \%::myconfig,$_[0]->get_open_ordered_qty,2); }, + text => t8('Ordered purchase'), + }, + ); + + map { $column_defs{$_}->{visible} = 1 } @visible; + + $report->set_options( + controller_class => 'DispositionManager', + output_format => 'HTML', + title => t8($title), + allow_pdf_export => 0, + allow_csv_export => 0, + allow_chart_export => 0, + no_data_message => !$noshow, + ); + $report->set_columns(%column_defs); + $report->set_column_order(@columns); + + unless ( $noshow ) { + if ($report->{options}{output_format} =~ /^(pdf|csv)$/i) { + $self->models->disable_plugin('paginated'); + } + $self->models->finalize; # for filter laundering + $self->models->set_report_generator_sort_options( + report => $report, sortable_columns => \@sortable + ); + } + my $parts = $self->_get_parts(0); + my $top = $self->render('disposition_manager/list_parts', { output => 0 }, + noshow => $noshow, + PARTS => $parts, + title => t8('Short onhand Ordered'), + ); + my $bottom = $noshow ? undef : $self->render( + 'disposition_manager/reorder_level_list/report_bottom', + { output => 0}, models => $self->models ); + $report->set_options( + raw_top_info_text => $top, + raw_bottom_info_text => $bottom, + ); +} + +sub action_add_to_purchase_basket{ + my ($self) = @_; + + my $employee = SL::DB::Manager::Employee->current; + + my $parts_to_add = delete($::form->{ids}) || []; + foreach my $id (@{ $parts_to_add }) { + my $part = SL::DB::Manager::Part->find_by(id => $id) + or die "Can't find part with id: $id\n"; + my $needed_qty = $part->order_qty < ($part->rop - $part->onhandqty) ? + $part->rop - $part->onhandqty + : $part->order_qty; + my $basket_part = SL::DB::PurchaseBasketItem->new( + part_id => $part->id, + qty => $needed_qty, + orderer_id => $employee->id, + )->save; + } + + $self->redirect_to( + controller => 'DispositionManager', + action => 'show_basket', + ); + +} + +sub action_show_basket { + my ($self) = @_; + + $::request->{layout}->add_javascripts( + 'kivi.DispositionManager.js', 'kivi.Part.js' + ); + my $basket_items = SL::DB::Manager::PurchaseBasketItem->get_all( + query => [ cleared => 'F' ], + with_objects => [ 'part', 'part.makemodels' ] + ); + $self->_setup_show_basket_action_bar; + $self->render( + 'disposition_manager/show_purchase_basket', + BASKET_ITEMS => $basket_items, + title => t8('Purchase basket'), + ); +} + +sub action_show_vendor_items { + my ($self) = @_; + + my $makemodels_parts; + if ($::form->{vendor_id}) { + $makemodels_parts = SL::DB::Manager::Part->get_all( + query => [ + 'purchase_basket_item.id' => undef, + 'makemodels.make' => $::form->{vendor_id}, + ], + sort_by => 'onhand', + with_objects => [ 'makemodels', 'purchase_basket_item' ] + ); + }; + + $self->render( + 'disposition_manager/_show_vendor_parts', + { layout => 0 }, + MAKEMODEL_ITEMS => $makemodels_parts + ); +} + +sub action_transfer_to_purchase_order { + my ($self) = @_; + my @error_report; + + my $basket_item_ids = $::form->{ids}; + my $vendor_item_ids = $::form->{vendor_part_ids}; + + unless (($basket_item_ids && scalar @{ $basket_item_ids}) + || ( $vendor_item_ids && scalar @{ $vendor_item_ids})) + { + $self->js->flash('error', t8('There are no items selected')); + return $self->js->render(); + } + + # check for same vendor + my %basket_id_vendor_id_map = + map {$::form->{basket_ids}->[$_] => $::form->{vendor_ids}->[$_]} + (0..$#{$::form->{vendor_ids}}); + + my $vendor_id = $::form->{vendor_id_selected} || $basket_id_vendor_id_map{@{$basket_item_ids}[0]} || $basket_id_vendor_id_map{@{$basket_item_ids}[0]}; + + my @different_vendor_ids = + grep { $basket_id_vendor_id_map{$_} ne $vendor_id } + @{$basket_item_ids}; + if (scalar @different_vendor_ids) { + $self->js->flash('error', t8('There are mulitple vendors selected')); + return $self->js->render(); + } + + $self->redirect_to( + controller => 'Order', + action => 'add_from_purchase_basket', + type => 'purchase_order', + basket_item_ids => $basket_item_ids || [], + vendor_item_ids => $vendor_item_ids || [], + vendor_id => $vendor_id, + ); +} + +sub action_delete_purchase_basket_items { + + my ($self) = @_; + my @error_report; + + my $basket_item_ids = $::form->{ids}; + + if ($basket_item_ids && scalar @{ $basket_item_ids}) { + SL::DB::Manager::PurchaseBasketItem->delete_all( + where => [ id => $basket_item_ids]); + } else { + $self->js->flash('error', t8('There are no items selected')); + return $self->js->render(); + } + + flash_later('info', t8('Selected items deleted')); + + $self->redirect_to( + controller => 'DispositionManager', + action => 'show_basket', + ); +} + +sub _get_parts { + my ($self, $ordered) = @_; + + my $query = <get_standard_dbh, $query); + return unless scalar @ids; + my $parts = SL::DB::Manager::Part->get_all( query => [ id => \@ids ] ); + my $parts_to_order = [ grep { !$_->get_open_ordered_qty } @{$parts} ]; + return $parts_to_order if !$ordered; + my $parts_ordered = [ + map { $_->id } grep { $_->get_open_ordered_qty } @{$parts} + ]; + return $parts_ordered if $ordered; +}; + +sub init_models { + my ($self) = @_; + my $parts1 = $self->_get_parts(1) || []; + my @parts = @{$parts1}; + my $get_models = SL::Controller::Helper::GetModels->new( + controller => $self, + model => 'Part', + sorted => { + _default => { + by => 'partnumber', + dir => 1, + }, + partnumber => $::locale->text('Part Number'), + description => $::locale->text('Description'), + }, + query => [ + (id => \@parts) x !!@parts, + (id => undef) x !@parts, + ], + paginated => { + form_params => [ qw(page per_page) ], + per_page => 35, + } + ); + return $get_models; +} + + + +sub _setup_list_action_bar { + my ($self) = @_; + for my $bar ($::request->layout->get('actionbar')) { + $bar->add( + action => [ + t8('Purchasebasket'), + submit => [ + '#form', { action => "DispositionManager/add_to_purchase_basket" } ], + tooltip => t8('Add to purchase basket'), + ], + ); + } +} + +sub _setup_show_basket_action_bar { + my ($self) = @_; + for my $bar ($::request->layout->get('actionbar')) { + $bar->add( + action => [ + t8('Reload'), + link => $self->url_for( + controller => 'DispositionManager', + action => 'show_basket', + ), + ], + action => [ + t8('Action'), + call => [ 'kivi.DispositionManager.create_purchase_order' ], + tooltip => t8('Create purchase order'), + ], + action => [ + t8('Delete'), + call => [ 'kivi.DispositionManager.delete_purchase_basket_items' ], + tooltip => t8('Delete selected from purchase basket'), + ], + ); + } +} +1; + +__END__ + +=encoding utf-8 + +=head1 NAME + +SL::Controller::DispositionManager Controller to manage purchase orders for parts + +=head1 DESCRIPTION + +This controller shows a list of parts using the filter minimum stock (rop). +From this list it is possible to put parts in a purchase basket to order. +It's also possible to put parts from the parts edit form in the purchase basket. + +From the purchase basket you can create a purchase order by using the filter vendor. +The quantity to order will be prefilled by the value min_qty_to_order from parts or +makemodel(vendor_parts) or default to qty 1. + +Tables: + +=over 2 + +=item purchase_basket + +=back + +Dependencies: + +=over 2 + +=item parts + +=item makemodels + + +=back + +=head1 URL ACTIONS + +=over 4 + +=item C + +List the parts by the filter min stock (rop) and not in an open purchase order. + +=item C + +Adds one or more parts to the purchase basket. + +=item C + +Shows a list with parts which are in the basket. +This list can be filtered by vendor. Then you can create a purchase order. +When filtered by vendor, a table with the parts from the vendor of the purchase basket and +a table with all parts from the vendor will be shown. From there you can mark +the parts and create an order + +=item C + +Transfers the marked and by vendor filtered parts to a purchase order. +Deletes the entry in the purchase basket. + +=back + +=head1 BUGS + +None yet. :) + +=head1 AUTHOR + +W. Hahn Ewh@futureworldsearch.netE + +=cut diff --git a/SL/Controller/EmailJournal.pm b/SL/Controller/EmailJournal.pm index 376dfb3d1..289427fb0 100644 --- a/SL/Controller/EmailJournal.pm +++ b/SL/Controller/EmailJournal.pm @@ -4,13 +4,34 @@ use strict; use parent qw(SL::Controller::Base); +use SL::ZUGFeRD; +use SL::Controller::ZUGFeRD; use SL::Controller::Helper::GetModels; use SL::DB::Employee; use SL::DB::EmailJournal; use SL::DB::EmailJournalAttachment; +use SL::Presenter::EmailJournal; +use SL::Presenter::Record qw(grouped_record_list); +use SL::Presenter::Tag qw(html_tag div_tag button_tag); use SL::Helper::Flash; -use SL::Locale::String; -use SL::System::TaskServer; +use SL::Locale::String qw(t8); + +use SL::DB::Order; +use SL::DB::Order::TypeData; +use SL::DB::DeliveryOrder; +use SL::DB::DeliveryOrder::TypeData; +use SL::DB::Reclamation; +use SL::DB::Reclamation::TypeData; +use SL::DB::Invoice; +use SL::DB::Invoice::TypeData; +use SL::DB::PurchaseInvoice; +use SL::DB::PurchaseInvoice::TypeData; + +use SL::DB::Manager::Customer; +use SL::DB::Manager::Vendor; + +use List::Util qw(first); +use List::MoreUtils qw(any); use Rose::Object::MakeMethods::Generic ( @@ -19,6 +40,178 @@ use Rose::Object::MakeMethods::Generic ); __PACKAGE__->run_before('add_stylesheet'); +__PACKAGE__->run_before('add_js'); + +my %RECORD_TYPES_INFO = ( + Order => { + controller => 'Order', + class => 'Order', + types => SL::DB::Order::TypeData->valid_types(), + }, + DeliveryOrder => { + controller => 'DeliveryOrder', + class => 'DeliveryOrder', + types => SL::DB::DeliveryOrder::TypeData->valid_types(), + }, + Reclamation => { + controller => 'Reclamation', + class => 'Reclamation', + types => SL::DB::Reclamation::TypeData->valid_types(), + }, + GlTransaction => { + controller => 'gl.pl', + class => 'GLTransaction', + types => [ + 'gl_transaction', + ], + }, + ArTransaction => { + controller => 'ar.pl', + class => 'Invoice', + types => [ + 'ar_transaction', + ], + }, + Invoice => { + controller => 'is.pl', + class => 'Invoice', + types => SL::DB::Invoice::TypeData->valid_types(), + }, + ApTransaction => { + controller => 'ap.pl', + class => 'PurchaseInvoice', + types => [ + 'ap_transaction', + ], + }, + PurchaseInvoice => { + controller => 'ir.pl', + class => 'PurchaseInvoice', + types => SL::DB::PurchaseInvoice::TypeData->valid_types(), + }, + GlRecordTemplate => { + controller => 'gl.pl', + class => 'RecordTemplate', + types => [ + 'gl_transaction_template', + ], + }, + ArRecordTemplate => { + controller => 'ar.pl', + class => 'RecordTemplate', + types => [ + 'ar_transaction_template', + ], + }, + ApRecordTemplate => { + controller => 'ap.pl', + class => 'RecordTemplate', + types => [ + 'ap_transaction_template', + ], + }, +); +my %RECORD_TYPE_TO_CONTROLLER = + map { + my $controller = $RECORD_TYPES_INFO{$_}->{controller}; + map { $_ => $controller } @{ $RECORD_TYPES_INFO{$_}->{types} } + } keys %RECORD_TYPES_INFO; +my %RECORD_TYPE_TO_MODEL = + map { + my $class = $RECORD_TYPES_INFO{$_}->{class}; + map { $_ => "SL::DB::$class" } @{ $RECORD_TYPES_INFO{$_}->{types} } + } keys %RECORD_TYPES_INFO; +my %RECORD_TYPE_TO_MANAGER = + map { + my $class = $RECORD_TYPES_INFO{$_}->{class}; + map { $_ => "SL::DB::Manager::$class" } @{ $RECORD_TYPES_INFO{$_}->{types} } + } keys %RECORD_TYPES_INFO; +my @ALL_RECORD_TYPES = + map { @{ $RECORD_TYPES_INFO{$_}->{types} } } keys %RECORD_TYPES_INFO; +my %RECORD_TYPE_TO_NR_KEY = + map { + my $model = $RECORD_TYPE_TO_MODEL{$_}; + if (any {$model eq $_} qw(SL::DB::Invoice SL::DB::PurchaseInvoice)) { + $_ => 'invnumber'; + } elsif (any {$model eq $_} qw(SL::DB::RecordTemplate)) { + $_ => 'template_name'; + } elsif (any {$model eq $_} qw(SL::DB::GLTransaction)) { + $_ => 'reference'; + } else { + my $type_data = SL::DB::Helper::TypeDataProxy->new($model, $_); + $_ => $type_data->properties('nr_key'); + } + } @ALL_RECORD_TYPES; + +# has do be done at runtime for translation to work +sub get_record_types_with_info { + my @record_types_with_info = (); + for my $record_class ( + 'SL::DB::Order', 'SL::DB::DeliveryOrder', 'SL::DB::Reclamation', + 'SL::DB::Invoice', 'SL::DB::PurchaseInvoice', + ) { + my $type_data = "${record_class}::TypeData"; + my $valid_types = $type_data->valid_types(); + for my $type (@$valid_types) { + push @record_types_with_info, { + record_type => $type, + text => $type_data->can('get3')->($type, 'text', 'type'), + customervendor => $type_data->can('get3')->($type, 'properties', 'customervendor'), + workflow_needed => $type_data->can('get3')->($type, 'properties', 'worflow_needed'), + can_workflow => ( + any { + $_ ne 'delete' && $type_data->can('get3')->($type, 'show_menu', $_) + } keys %{$type_data->can('get')->($type, 'show_menu')} + ), + }; + } + } + push @record_types_with_info, ( + # transactions + # gl_transaction can be for vendor and customer + { record_type => 'gl_transaction', customervendor => 'customer', workflow_needed => 0, can_workflow => 1, text => t8('GL Transaction')}, + { record_type => 'gl_transaction', customervendor => 'vendor', workflow_needed => 0, can_workflow => 1, text => t8('GL Transaction')}, + { record_type => 'ar_transaction', customervendor => 'customer', workflow_needed => 0, can_workflow => 1, text => t8('AR Transaction')}, + { record_type => 'ap_transaction', customervendor => 'vendor', workflow_needed => 0, can_workflow => 1, text => t8('AP Transaction')}, + # templates + { record_type => 'gl_transaction_template', is_template => 1, customervendor => 'customer', workflow_needed => 0, can_workflow => 0, text => t8('GL Transaction')}, + { record_type => 'gl_transaction_template', is_template => 1, customervendor => 'vendor', workflow_needed => 0, can_workflow => 0, text => t8('GL Transaction')}, + { record_type => 'ar_transaction_template', is_template => 1, customervendor => 'customer', workflow_needed => 0, can_workflow => 0, text => t8('AR Transaction')}, + { record_type => 'ap_transaction_template', is_template => 1, customervendor => 'vendor', workflow_needed => 0, can_workflow => 0, text => t8('AP Transaction')}, + ); + return @record_types_with_info; +} + +# has do be done at runtime for translation to work +sub get_record_types_to_text { + my @record_types_with_info = get_record_types_with_info(); + + my %record_types_to_text = (); + $record_types_to_text{$_->{record_type}} = $_->{text} for @record_types_with_info; + $record_types_to_text{'catch_all'} = t8("Catch-all"); + + return %record_types_to_text; +} + +sub record_types_for_customer_vendor_type_and_action { + my ($self, $customer_vendor_type, $action) = @_; + return [ + map { $_->{record_type} } + grep { + # No gl_transaction in standard workflows + # They can't be filtered by customer/vendor or open/closed and polute the list + ($_->{record_type} ne 'gl_transaction') + } + grep { + ($_->{customervendor} eq $customer_vendor_type) + && ($action eq 'workflow_record' ? $_->{can_workflow} : 1) + && ($action eq 'create_new' ? $_->{workflow_needed} : 1) + && ($action eq 'linking_record' ? !$_->{is_template} : 1) + && ($action eq 'template_record' ? $_->{is_template} : 1) + } + $self->get_record_types_with_info() + ]; +} # # actions @@ -28,15 +221,22 @@ sub action_list { my ($self) = @_; $::auth->assert('email_journal'); + # default filter + $::form->{filter} ||= {"obsolete:eq_ignore_empty" => 0}; if ( $::instance_conf->get_email_journal == 0 ) { flash('info', $::locale->text('Storing the emails in the journal is currently disabled in the client configuration.')); } $self->setup_list_action_bar; + my @record_types_with_info = $self->get_record_types_with_info(); + my %record_types_to_text = $self->get_record_types_to_text(); $self->render('email_journal/list', title => $::locale->text('Email journal'), ENTRIES => $self->models->get, - MODELS => $self->models); + MODELS => $self->models, + RECORD_TYPES_WITH_INFO => \@record_types_with_info, + RECORD_TYPES_TO_TEXT => \%record_types_to_text, + ); } sub action_show { @@ -52,10 +252,86 @@ sub action_show { $::form->error(t8('You do not have permission to access this entry.')); } + my @record_types_with_info = $self->get_record_types_with_info(); + my %record_types_to_text = $self->get_record_types_to_text(); + + my $customer = $self->find_customer_vendor_from_email('customer', $self->entry); + my $vendor = $self->find_customer_vendor_from_email('vendor' , $self->entry); + + my $record_type_info = + first {$_->{record_type} eq $self->entry->record_type} + @record_types_with_info; + my $cv_type_found = $record_type_info ? $record_type_info->{customervendor} + : defined $vendor ? 'vendor' + : 'customer'; + + my $record_types = $self->record_types_for_customer_vendor_type_and_action( + $cv_type_found, 'workflow_record' + ); + $self->setup_show_action_bar; - $self->render('email_journal/show', - title => $::locale->text('View sent email'), - back_to => $back_to); + $self->render( + 'email_journal/show', + title => $::locale->text('View email'), + CUSTOMER => $customer, + VENDOR => $vendor, + CV_TYPE_FOUND => $cv_type_found, + RECORD_TYPES_WITH_INFO => \@record_types_with_info, + RECORD_TYPES_TO_TEXT => \%record_types_to_text, + back_to => $back_to, + ); +} + +sub action_attachment_preview { + my ($self) = @_; + + eval { + $::auth->assert('email_journal'); + + my $attachment_id = $::form->{attachment_id}; + die "no 'attachment_id' was given" unless $attachment_id; + + my $attachment; + $attachment = SL::DB::EmailJournalAttachment->new( + id => $attachment_id, + )->load; + + + if (!$self->can_view_all + && $attachment->email_journal->sender_id + && ($attachment->email_journal->sender_id != SL::DB::Manager::Employee->current->id)) { + $::form->error(t8('You do not have permission to access this entry.')); + } + + my $output = SL::Presenter::EmailJournal::attachment_preview( + $attachment, + style => "height: 1800px" + ); + + $self->render( \$output, { layout => 0, process => 0,}); + } or do { + $self->render('generic/error', { layout => 0 }, label_error => $@); + }; +} + +sub action_show_attachment { + my ($self) = @_; + + $::auth->assert('email_journal'); + + my $attachment_id = $::form->{attachment_id}; + my $attachment = SL::DB::EmailJournalAttachment->new(id => $attachment_id)->load; + + if (!$self->can_view_all && ($attachment->email_journal->sender_id != SL::DB::Manager::Employee->current->id)) { + $::form->error(t8('You do not have permission to access this entry.')); + } + + return $self->send_file( + \$attachment->content, + name => $attachment->name, + type => $attachment->mime_type, + content_disposition => 'inline', + ); } sub action_download_attachment { @@ -69,13 +345,188 @@ sub action_download_attachment { $::form->error(t8('You do not have permission to access this entry.')); } my $ref = \$attachment->content; - if ( $attachment->file_id > 0 ) { + # hot hot fix don't offer some random version of this file if we have a real saved state in the email journal + if (!$ref && $attachment->file_id > 0 ) { my $file = SL::File->get(id => $attachment->file_id ); $ref = $file->get_content if $file; } $self->send_file($ref, name => $attachment->name, type => $attachment->mime_type); } +sub action_apply_record_action { + my ($self) = @_; + my $email_journal_id = $::form->{email_journal_id}; + my $attachment_id = $::form->{attachment_id}; + my $customer_vendor = $::form->{customer_vendor_selection}; + my $customer_vendor_id = $::form->{"${customer_vendor}_id"}; + my $action = $::form->{action_selection}; + my $record_id = $::form->{"record_id"}; + my $record_type = $::form->{"record_type"}; + $record_type ||= $::form->{"${customer_vendor}_${action}_type_selection"}; + + die t8("No record is selected.") unless $record_id || $action eq 'new_record'; + die t8("No record type is selected.") unless $record_type; + die "no 'email_journal_id' was given" unless $email_journal_id; + die "no 'customer_vendor_selection' was given" unless $customer_vendor; + die "no 'action_selection' was given" unless $action; + + if ($action eq 'linking_record') { + return $self->link_and_add_attachment_to_record({ + email_journal_id => $email_journal_id, + attachment_id => $attachment_id, + record_type => $record_type, + record_id => $record_id, + }); + } + + my %additional_params = (); + if ($action eq 'new_record') { + $additional_params{action} = 'add_from_email_journal'; + $additional_params{"${customer_vendor}_id"} = $customer_vendor_id; + } elsif ($action eq 'template_record') { + $additional_params{action} = 'load_record_template_from_email_journal'; + $additional_params{id} = $record_id; + $additional_params{form_defaults} = { + email_journal_id => $email_journal_id, + email_attachment_id => $attachment_id, + callback => $::form->{back_to}, + }; + } else { # workflow_record + $additional_params{action} = 'edit_with_email_journal_workflow'; + $additional_params{id} = $record_id; + } + + $self->redirect_to( + controller => $RECORD_TYPE_TO_CONTROLLER{$record_type}, + type => $record_type, + email_journal_id => $email_journal_id, + email_attachment_id => $attachment_id, + callback => $::form->{back_to}, + %additional_params, + ); +} + +sub action_ap_transaction_template_with_zugferd_import { + my ($self) = @_; + my $email_journal_id = $::form->{email_journal_id}; + die "no 'email_journal_id' was given" unless $email_journal_id; + + my $record_id = $::form->{"record_id"}; + my $record_type = $::form->{"record_type"}; + die "ZUGFeRD-Import only implemented for ap transaction templates" unless $record_type == 'ap_transaction'; + + my $attachment_id = $::form->{attachment_id}; + + my $form_defaults; + if ($attachment_id) { + my $attachment = SL::DB::EmailJournalAttachment->new(id => $attachment_id)->load(); + my $content = $attachment->content; # scalar ref + + if ($content =~ m/^%PDF|<\?xml/) { + + my %res; + if ( $content =~ m/^%PDF/ ) { + %res = %{SL::ZUGFeRD->extract_from_pdf($content)}; + } else { + %res = %{SL::ZUGFeRD->extract_from_xml($content)}; + } + + if ($res{'result'} == SL::ZUGFeRD::RES_OK()) { + my $ap_template = SL::DB::RecordTemplate->new(id => $record_id)->load(); + my $vendor = $ap_template->vendor; + + $form_defaults = SL::Controller::ZUGFeRD->build_ap_transaction_form_defaults(\%res, vendor => $vendor); + flash_later('info', + t8("The ZUGFeRD/Factur-X invoice '#1' has been loaded.", $attachment->name)); + } + } + } + + $form_defaults->{email_journal_id} = $email_journal_id; + $form_defaults->{email_attachment_id} = $attachment_id; + $form_defaults->{callback} = $::form->{back_to}; + + $self->redirect_to( + controller => 'ap.pl', + action => 'load_zugferd', + record_template_id => $record_id, + form_defaults => $form_defaults, + ); +} + +sub action_update_attachment_preview { + my ($self) = @_; + $::auth->assert('email_journal'); + my $attachment_id = $::form->{attachment_id}; + + my $attachment; + $attachment = SL::DB::EmailJournalAttachment->new( + id => $attachment_id, + )->load if $attachment_id; + + $self->js + ->replaceWith('#attachment_preview', + SL::Presenter::EmailJournal::attachment_preview( + $attachment, + style => "height:1800px" + ) + ) + ->render(); +} + +sub action_update_record_list { + my ($self) = @_; + $::auth->assert('email_journal'); + my $customer_vendor_type = $::form->{customer_vendor_selection}; + my $customer_vendor_id = $::form->{"${customer_vendor_type}_id"}; + my $action = $::form->{action_selection}; + my $record_type = $::form->{"${customer_vendor_type}_${action}_type_selection"}; + my $record_number = $::form->{record_number}; + my $with_closed = $::form->{with_closed}; + + $record_type ||= $self->record_types_for_customer_vendor_type_and_action($customer_vendor_type, $action); + + my @records = $self->get_records_for_types( + $record_type, + customer_vendor_type => $customer_vendor_type, + customer_vendor_id => $customer_vendor_id, + record_number => $record_number, + with_closed => $with_closed, + ); + + my $new_div = $self->get_records_div(\@records); + + $self->js->replaceWith('#record_list', $new_div); + $self->js->hide('#record_toggle_closed') if scalar @records < 20; + $self->js->show('#record_toggle_open') if scalar @records < 20; + $self->js->render(); +} + +sub action_toggle_obsolete { + my ($self) = @_; + + $::auth->assert('email_journal'); + + $self->entry(SL::DB::EmailJournal->new(id => $::form->{id})->load); + + if (!$self->can_view_all && ($self->entry->sender_id != SL::DB::Manager::Employee->current->id)) { + $::form->error(t8('You do not have permission to access this entry.')); + } + + $self->entry->obsolete(!$self->entry->obsolete); + $self->entry->save; + + $self->js + ->val('#obsolete', $self->entry->obsolete_as_bool_yn) + ->flash('info', + $self->entry->obsolete ? + $::locale->text('Email marked as obsolete.') + : $::locale->text('Email marked as not obsolete.') + )->render(); + + return; +} + # # filters # @@ -88,6 +539,199 @@ sub add_stylesheet { # helpers # +sub get_records_for_types { + my ($self, $record_types, %params) = @_; + $record_types = [ $record_types ] unless ref $record_types eq 'ARRAY'; + + my $cv_type = $params{customer_vendor_type}; + my $cv_id = $params{customer_vendor_id}; + my $record_number = $params{record_number}; + my $with_closed = $params{with_closed}; + + my @records = (); + foreach my $record_type (@$record_types) { + my $manager = $RECORD_TYPE_TO_MANAGER{$record_type}; + my $model = $RECORD_TYPE_TO_MODEL{$record_type}; + my %additional_where = (); + if ($cv_type && $cv_id && $record_type !~ /^gl_transaction/) { + $additional_where{"${cv_type}_id"} = $cv_id; + } + if ($record_number) { + my $nr_key = $RECORD_TYPE_TO_NR_KEY{$record_type}; + $additional_where{$nr_key} = { ilike => "%$record_number%" }; + } + unless ($with_closed) { + if (any {$_ eq 'closed'} $model->meta->columns) { + $additional_where{closed} = 0; + } elsif (any {$_ eq 'paid'} $model->meta->columns) { + $additional_where{amount} = { gt => \'paid' }; + } + } + my $records_of_type = $manager->get_all( + where => [ + $manager->type_filter($record_type), + %additional_where, + ], + ); + push @records, @$records_of_type; + } + + return @records; +} + +sub get_records_div { + my ($self, $records) = @_; + my $div = div_tag( + grouped_record_list( + $records, + with_columns => [ qw(email_journal_action) ], + ), + id => 'record_list', + ); + return $div; +} + +sub link_and_add_attachment_to_record { + my ($self, $params) = @_; + + my $email_journal_id = $params->{email_journal_id}; + my $attachment_id = $params->{attachment_id}; + my $record_type = $params->{record_type}; + my $record_id = $params->{record_id}; + + my $record_type_model = $RECORD_TYPE_TO_MODEL{$record_type}; + my $record = $record_type_model->new(id => $record_id)->load; + my $email_journal = SL::DB::EmailJournal->new(id => $email_journal_id)->load; + + if ($attachment_id) { + my $attachment = SL::DB::EmailJournalAttachment->new(id => $attachment_id)->load; + $attachment->add_file_to_record($record); + } + + $email_journal->link_to_record($record); + + $self->js->flash('info', + $::locale->text('Linked email and attachment to ') . $record->displayable_name + )->render(); +} + +sub find_customer_vendor_from_email { + my ($self, $cv_type, $email_journal) = @_; + + my $manager = $cv_type eq 'customer' ? 'SL::DB::Manager::Customer' + : $cv_type eq 'vendor' ? 'SL::DB::Manager::Vendor' + : die "No valid customer vendor option: $cv_type"; + + my $email_address = $email_journal->from; + $email_address =~ s/.*<(.*)>/$1/; # address can look like "name surname " + + # Separate query otherwise cv without contacts and shipto is not found + my $customer_vendor; + $customer_vendor ||= $manager->get_first( + where => [ + or => [ + email => $email_address, + cc => $email_address, + bcc => $email_address, + ], + ], + ); + $customer_vendor ||= $manager->get_first( + where => [ + or => [ + 'contacts.cp_email' => $email_address, + 'contacts.cp_privatemail' => $email_address, + ], + ], + with_objects => [ 'contacts'], + ); + $customer_vendor ||= $manager->get_first( + where => [ + or => [ + 'shipto.shiptoemail' => $email_address, + ], + ], + with_objects => [ 'shipto' ], + ); + if ($manager eq 'SL::DB::Manager::Customer') { + $customer_vendor ||= $manager->get_first( + where => [ + or => [ + 'additional_billing_addresses.email' => $email_address, + ], + ], + with_objects => [ 'additional_billing_addresses' ], + ); + } + + # if no exact match is found search for domain and match only on one hit + unless ($customer_vendor) { + my $email_domain = $email_address; + $email_domain =~ s/.*@(.*)/$1/; + my @domain_hits_cusotmer_vendor = (); + my @domain_hits = (); + push @domain_hits, @{$manager->get_all( + where => [ + or => [ + email => {ilike => "%$email_domain"}, + cc => {ilike => "%$email_domain"}, + bcc => {ilike => "%$email_domain"}, + ], + ], + )}; + push @domain_hits, @{$manager->get_all( + where => [ + or => [ + 'contacts.cp_email' => {ilike => "%$email_domain"}, + 'contacts.cp_privatemail' => {ilike => "%$email_domain"}, + ], + ], + with_objects => [ 'contacts'], + )}; + push @domain_hits, @{$manager->get_all( + where => [ + or => [ + 'shipto.shiptoemail' => {ilike => "%$email_domain"}, + ], + ], + with_objects => [ 'shipto' ], + )}; + push @domain_hits, @{$manager->get_all( + where => [ + or => [ + 'shipto.shiptoemail' => {ilike => "%$email_domain"}, + ], + ], + with_objects => [ 'shipto' ], + )}; + if ($manager eq 'SL::DB::Manager::Customer') { + push @domain_hits, @{$manager->get_all( + where => [ + or => [ + 'additional_billing_addresses.email' => {ilike => "%$email_domain"}, + ], + ], + with_objects => [ 'additional_billing_addresses' ], + )}; + } + # update on only one unique customer_vendor + if (scalar @domain_hits) { + my $first_customer_vendor = $domain_hits[0]; + unless (any {$_->id != $first_customer_vendor->id} @domain_hits) { + $customer_vendor = $first_customer_vendor; + } + } + } + + return $customer_vendor; +} + +sub add_js { + $::request->{layout}->use_javascript("${_}.js") for qw( + kivi.EmailJournal + ); +} + sub init_can_view_all { $::auth->assert('email_employee_readall', 1) } sub init_models { @@ -108,6 +752,9 @@ sub init_models { sent_on => t8('Sent on'), status => t8('Status'), extended_status => t8('Extended status'), + record_type => t8('Record Type'), + obsolete => t8('Obsolete'), + linked_to => t8('Linked to'), }, ); } @@ -128,11 +775,22 @@ sub init_filter_summary { @filters; my %status = ( - failed => $::locale->text('failed'), - ok => $::locale->text('succeeded'), + send_failed => $::locale->text('send failed'), + sent => $::locale->text('sent'), + imported => $::locale->text('imported'), ); push @filter_strings, $status{ $filter->{'status:eq_ignore_empty'} } if $filter->{'status:eq_ignore_empty'}; + + my %record_type_to_text = $self->get_record_types_to_text(); + push @filter_strings, $record_type_to_text{ $filter->{'record_type:eq_ignore_empty'} } if $filter->{'record_type:eq_ignore_empty'}; + + push @filter_strings, $::locale->text('Obsolete') if $filter->{'obsolete:eq_ignore_empty'} eq '1'; + push @filter_strings, $::locale->text('Not obsolete') if $filter->{'obsolete:eq_ignore_empty'} eq '0'; + + push @filter_strings, $::locale->text('Linked') if $filter->{'linked_to:eq_ignore_empty'} eq '1'; + push @filter_strings, $::locale->text('Not linked') if $filter->{'linked_to:eq_ignore_empty'} eq '0'; + return join ', ', @filter_strings; } diff --git a/SL/Controller/File.pm b/SL/Controller/File.pm index 8dacec241..3f5925a91 100644 --- a/SL/Controller/File.pm +++ b/SL/Controller/File.pm @@ -55,27 +55,34 @@ __PACKAGE__->run_before('check_object_params', only => [ qw(list ajax_delete aja # model: base name of the rose model # right: access right used for import my %file_types = ( - 'sales_quotation' => { gen => 1, gltype => '', dir =>'SalesQuotation', model => 'Order', right => 'import_ar' }, - 'sales_order' => { gen => 5, gltype => '', dir =>'SalesOrder', model => 'Order', right => 'import_ar' }, - 'sales_delivery_order' => { gen => 1, gltype => '', dir =>'SalesDeliveryOrder', model => 'DeliveryOrder', right => 'import_ar' }, - 'invoice' => { gen => 1, gltype => 'ar', dir =>'SalesInvoice', model => 'Invoice', right => 'import_ar' }, - 'invoice_for_advance_payment' => { gen => 1, gltype => 'ar', dir =>'SalesInvoice', model => 'Invoice', right => 'import_ar' }, - 'final_invoice' => { gen => 1, gltype => 'ar', dir =>'SalesInvoice', model => 'Invoice', right => 'import_ar' }, - 'credit_note' => { gen => 1, gltype => '', dir =>'CreditNote', model => 'Invoice', right => 'import_ar' }, - 'request_quotation' => { gen => 7, gltype => '', dir =>'RequestForQuotation', model => 'Order', right => 'import_ap' }, - 'purchase_order' => { gen => 7, gltype => '', dir =>'PurchaseOrder', model => 'Order', right => 'import_ap' }, - 'purchase_delivery_order' => { gen => 7, gltype => '', dir =>'PurchaseDeliveryOrder',model => 'DeliveryOrder', right => 'import_ap' }, - 'purchase_invoice' => { gen => 6, gltype => 'ap', dir =>'PurchaseInvoice', model => 'PurchaseInvoice',right => 'import_ap' }, - 'vendor' => { gen => 0, gltype => '', dir =>'Vendor', model => 'Vendor', right => 'xx' }, - 'customer' => { gen => 1, gltype => '', dir =>'Customer', model => 'Customer', right => 'xx' }, - 'project' => { gen => 0, gltype => '', dir =>'Project', model => 'Project', right => 'xx' }, - 'part' => { gen => 0, gltype => '', dir =>'Part', model => 'Part', right => 'xx' }, - 'gl_transaction' => { gen => 6, gltype => 'gl', dir =>'GeneralLedger', model => 'GLTransaction', right => 'import_ap' }, - 'draft' => { gen => 0, gltype => '', dir =>'Draft', model => 'Draft', right => 'xx' }, - 'csv_customer' => { gen => 1, gltype => '', dir =>'Reports', model => 'Customer', right => 'xx' }, - 'csv_vendor' => { gen => 1, gltype => '', dir =>'Reports', model => 'Vendor', right => 'xx' }, - 'shop_image' => { gen => 0, gltype => '', dir =>'ShopImages', model => 'Part', right => 'xx' }, - 'letter' => { gen => 7, gltype => '', dir =>'Letter', model => 'Letter', right => 'sales_letter_edit | purchase_letter_edit' }, + 'sales_quotation' => { gen => 7, gltype => '', dir =>'SalesQuotation', model => 'Order', right => 'import_ar' }, + 'sales_order_intake' => { gen => 7, gltype => '', dir =>'SalesOrderIntake', model => 'Order', right => 'import_ar' }, + 'sales_order' => { gen => 7, gltype => '', dir =>'SalesOrder', model => 'Order', right => 'import_ar' }, + 'sales_delivery_order' => { gen => 7, gltype => '', dir =>'SalesDeliveryOrder', model => 'DeliveryOrder', right => 'import_ar' }, + 'sales_reclamation' => { gen => 7, gltype => '', dir =>'SalesReclamation', model => 'Reclamation', right => 'import_ar' }, + 'invoice' => { gen => 7, gltype => 'ar', dir =>'SalesInvoice', model => 'Invoice', right => 'import_ar' }, + 'invoice_for_advance_payment' => { gen => 7, gltype => 'ar', dir =>'SalesInvoice', model => 'Invoice', right => 'import_ar' }, + 'final_invoice' => { gen => 7, gltype => 'ar', dir =>'SalesInvoice', model => 'Invoice', right => 'import_ar' }, + 'credit_note' => { gen => 7, gltype => '', dir =>'CreditNote', model => 'Invoice', right => 'import_ar' }, + 'request_quotation' => { gen => 7, gltype => '', dir =>'RequestForQuotation', model => 'Order', right => 'import_ap' }, + 'purchase_quotation_intake' => { gen => 7, gltype => '', dir =>'PurchaseQuotationIntake', model => 'Order', right => 'import_ap' }, + 'purchase_order' => { gen => 7, gltype => '', dir =>'PurchaseOrder', model => 'Order', right => 'import_ap' }, + 'purchase_order_confirmation' => { gen => 7, gltype => '', dir =>'PurchaseOrderConfirmation', model => 'Order', right => 'import_ap' }, + 'purchase_delivery_order' => { gen => 7, gltype => '', dir =>'PurchaseDeliveryOrder', model => 'DeliveryOrder', right => 'import_ap' }, + 'purchase_reclamation' => { gen => 7, gltype => '', dir =>'PurchaseReclamation', model => 'Reclamation', right => 'import_ap' }, + 'purchase_invoice' => { gen => 7, gltype => 'ap', dir =>'PurchaseInvoice', model => 'PurchaseInvoice',right => 'import_ap' }, + 'supplier_delivery_order' => { gen => 7, gltype => '', dir =>'SupplierDeliveryOrder', model => 'DeliveryOrder', right => 'import_ap' }, + 'rma_delivery_order' => { gen => 7, gltype => '', dir =>'RMADeliveryOrder', model => 'DeliveryOrder', right => 'import_ar' }, + 'vendor' => { gen => 0, gltype => '', dir =>'Vendor', model => 'Vendor', right => 'xx' }, + 'customer' => { gen => 1, gltype => '', dir =>'Customer', model => 'Customer', right => 'xx' }, + 'project' => { gen => 0, gltype => '', dir =>'Project', model => 'Project', right => 'xx' }, + 'part' => { gen => 0, gltype => '', dir =>'Part', model => 'Part', right => 'xx' }, + 'gl_transaction' => { gen => 6, gltype => 'gl', dir =>'GeneralLedger', model => 'GLTransaction', right => 'import_ap' }, + 'draft' => { gen => 0, gltype => '', dir =>'Draft', model => 'Draft', right => 'xx' }, + 'csv_customer' => { gen => 1, gltype => '', dir =>'Reports', model => 'Customer', right => 'xx' }, + 'csv_vendor' => { gen => 1, gltype => '', dir =>'Reports', model => 'Vendor', right => 'xx' }, + 'shop_image' => { gen => 0, gltype => '', dir =>'ShopImages', model => 'Part', right => 'xx' }, + 'letter' => { gen => 7, gltype => '', dir =>'Letter', model => 'Letter', right => 'sales_letter_edit | purchase_letter_edit' }, ); #--- 4 locale ---# @@ -150,8 +157,8 @@ sub action_ajax_unimport { sub action_ajax_rename { my ($self) = @_; - my ($id, $version) = split /_/, $::form->{id}; - my $file = SL::File->get(id => $id); + my $guid = $::form->{id}; + my $file = SL::File->get(guid => $guid); if ( ! $file ) { $self->js->flash('error', $::locale->text('File not exists !'))->render(); return; @@ -272,7 +279,7 @@ sub action_ajax_files_uploaded { ); if ($existobj) { - push @existing, $existobj->id.'_'.$sfile->file_name; + push @existing, ($existobj->versions)[0]->file_version->guid.'_'.$sfile->file_name; } else { my $fileobj = SL::File->save(object_id => $self->object_id, object_type => $self->object_type, @@ -386,16 +393,10 @@ sub _delete_all { my ($self, $do_unimport, $infotext) = @_; my $files = ''; my $ids = $::form->{ids}; - foreach my $id_version (@{ $::form->{$ids} || [] }) { - my ($id, $version) = split /_/, $id_version; - my $dbfile = SL::File->get(id => $id); - if ( $dbfile ) { - if ( $version ) { - $dbfile->version($version); - $files .= ' ' . $dbfile->file_name if $dbfile->delete_version; - } else { - $files .= ' ' . $dbfile->file_name if $dbfile->delete; - } + foreach my $version_guid (@{ $::form->{$ids} || [] }) { + my $dbfile = SL::File->get(guid => $version_guid); + if ($dbfile) { + $files .= ' ' . $dbfile->file_name if $dbfile->delete_file_version; } } $self->js->flash('info', $infotext . $files) if $files; @@ -501,9 +502,9 @@ sub _mk_render { $self->js->html('#'.$self->file_type.'_list_'.$self->object_type, $output); if ( $self->existing && scalar(@{$self->existing}) > 0) { my $first = shift @{$self->existing}; - my ($first_id, $sfile) = split('_', $first, 2); - my $file = SL::File->get(id => $first_id ); - $self->js->run('kivi.File.askForRename', $first_id, $file->file_type, $file->file_name, $sfile, join (',', @{$self->existing}), $self->is_global); + my ($first_guid, $sfile) = split('_', $first, 2); + my $file = SL::File->get(guid => $first_guid ); + $self->js->run('kivi.File.askForRename', $first_guid, $file->file_type, $file->file_name, $sfile, join (',', @{$self->existing}), $self->is_global); } $self->js->render(); } else { @@ -628,7 +629,7 @@ sub _get_sources { return @sources; } -# ignores all errros +# ignores all errors # todo: cache thumbs? sub _create_thumbnail { my ($file, $size) = @_; diff --git a/SL/Controller/FinancialControllingReport.pm b/SL/Controller/FinancialControllingReport.pm index f4f09ff09..7dfb9243d 100644 --- a/SL/Controller/FinancialControllingReport.pm +++ b/SL/Controller/FinancialControllingReport.pm @@ -16,7 +16,7 @@ use Rose::Object::MakeMethods::Generic ( 'scalar --get_set_init' => [ qw(project_types models) ], ); -__PACKAGE__->run_before(sub { $::auth->assert('sales_order_edit'); }); +__PACKAGE__->run_before(sub { $::auth->assert('sales_financial_controlling'); }); my %sort_columns = ( ordnumber => t8('Order'), @@ -46,8 +46,9 @@ sub action_list { sub prepare_report { my ($self) = @_; - my $report = SL::ReportGenerator->new(\%::myconfig, $::form); - $self->{report} = $report; + my $report = SL::ReportGenerator->new(\%::myconfig, $::form); + $report->{title} = t8('Financial Controlling Report'); + $self->{report} = $report; my @columns = qw(customer globalprojectnumber globalproject_type transaction_description ordnumber net_amount delivered_amount delivered_amount_p billed_amount billed_amount_p paid_amount paid_amount_p billable_amount billable_amount_p other_amount); @@ -277,12 +278,7 @@ sub link_to { if ($object->isa('SL::DB::Order')) { my $type = $object->type; my $id = $object->id; - - if ($::instance_conf->get_feature_experimental_order) { - return "controller.pl?action=Order/$action&type=$type&id=$id"; - } else { - return "oe.pl?action=$action&type=$type&vc=customer&id=$id"; - } + return "controller.pl?action=Order/$action&type=$type&id=$id"; } if ($object->isa('SL::DB::Customer')) { my $id = $object->id; diff --git a/SL/Controller/FinancialOverview.pm b/SL/Controller/FinancialOverview.pm index c0c7d183c..edfe33da4 100644 --- a/SL/Controller/FinancialOverview.pm +++ b/SL/Controller/FinancialOverview.pm @@ -41,7 +41,7 @@ sub prepare_report { my ($self) = @_; $self->report(SL::ReportGenerator->new(\%::myconfig, $::form)); - + $self->report->{title} =t8('Financial Overview'); my @columns = (qw(year quarter month), @{ $self->types }); $self->number_columns([ grep { !m/^(?:month|year|quarter)$/ } @columns ]); @@ -141,9 +141,9 @@ sub calculate_one_time_data { my $month = $object->transdate->month - 1; my $tdata = $self->data->{$type}; - $tdata->{months}->[$month] += $object->netamount; - $tdata->{quarters}->[int($month / 3)] += $object->netamount; - $tdata->{year} += $object->netamount; + $tdata->{months}->[$month] += $object->netamount_base_currency; + $tdata->{quarters}->[int($month / 3)] += $object->netamount_base_currency; + $tdata->{year} += $object->netamount_base_currency; } } } @@ -151,23 +151,52 @@ sub calculate_one_time_data { sub calculate_periodic_invoices { my ($self) = @_; + my %billed_once_item_ids; my $start_date = DateTime->new(year => $self->year, month => 1, day => 1, time_zone => $::locale->get_local_time_zone); my $end_date = DateTime->new(year => $self->year, month => 12, day => 31, time_zone => $::locale->get_local_time_zone); - $self->calculate_one_periodic_invoice(config => $_, start_date => $start_date, end_date => $end_date) for @{ $self->objects->{periodic_invoices_cfg} }; + foreach my $config (@{ $self->objects->{periodic_invoices_cfg} }) { + $self->calculate_one_periodic_invoice( + config => $config, + start_date => $start_date, + end_date => $end_date, + billed_once_item_ids => \%billed_once_item_ids, + ); + } } sub calculate_one_periodic_invoice { my ($self, %params) = @_; # Calculate sales order advance - my $net = $params{config}->order->netamount * $params{config}->get_billing_period_length / $params{config}->get_order_value_period_length; my $sord = $self->data->{sales_orders}; + my ($net, $net_once) = (0, 0); + + foreach my $item (@{ $params{config}->order->orderitems }) { + next if $item->recurring_billing_mode eq 'never'; + + my $item_net = $item->qty * (1 - $item->discount) * $item->sellprice; + + if ($item->recurring_billing_mode eq 'once') { + next if $item->recurring_billing_invoice_id || $params{billed_once_invoice_id}->{$item->id}; + + $params{billed_once_invoice_id}->{$item->id} = 1; + $net_once += $item_net; + + } else { + $net += $item_net; + } + } + + $net = $net * $params{config}->get_billing_period_length / $params{config}->get_order_value_period_length; + foreach my $date ($params{config}->calculate_invoice_dates(start_date => $params{start_date}, end_date => $params{end_date}, past_dates => 1)) { - $sord->{months }->[ $date->month - 1 ] += $net; - $sord->{quarters}->[ $date->quarter - 1 ] += $net; - $sord->{year} += $net; + $sord->{months }->[ $date->month - 1 ] += $net + $net_once; + $sord->{quarters}->[ $date->quarter - 1 ] += $net + $net_once; + $sord->{year} += $net + $net_once; + + $net_once = 0; } # Calculate total sales order value diff --git a/SL/Controller/GenericPresenterTest.pm b/SL/Controller/GenericPresenterTest.pm new file mode 100644 index 000000000..6599f6c28 --- /dev/null +++ b/SL/Controller/GenericPresenterTest.pm @@ -0,0 +1,27 @@ +package SL::Controller::GenericPresenterTest; + +use strict; + +use parent qw(SL::Controller::Base); + +sub action_show { + my ($self) = @_; + + $self->render( + 'presenter/test_page', + defaults => { + from_date => '1.2.2022', + to_date => '3.4.2022', + dialog => { + year => '2022', # numeric year + type => 'monthly', # the radio button selection: + # 'yearly', 'monthly', 'quarterly' + quarter => 'B', # the quarter as a letter code: + # 'A', 'B', 'C', 'D' A being 1st quarter etc. + month => '6', # numeric month + } + } + ); +} + +1; diff --git a/SL/Controller/Helper/ReportGenerator.pm b/SL/Controller/Helper/ReportGenerator.pm index be9b8306d..becc19659 100644 --- a/SL/Controller/Helper/ReportGenerator.pm +++ b/SL/Controller/Helper/ReportGenerator.pm @@ -12,7 +12,9 @@ use SL::ReportGenerator; use Exporter 'import'; our @EXPORT = qw( - action_report_generator_export_as_pdf action_report_generator_export_as_csv + action_report_generator_export_as_pdf + action_report_generator_export_as_csv + action_report_generator_export_as_chart action_report_generator_back report_generator_do report_generator_list_objects ); @@ -26,7 +28,7 @@ sub _setup_action_bar { for my $bar ($::request->layout->get('actionbar')) { $bar->add( action => [ - $type eq 'pdf' ? $::locale->text('PDF export') : $::locale->text('CSV export'), + $type eq 'pdf' ? $::locale->text('PDF export') : $type eq 'csv' ? $::locale->text('CSV export') : $::locale->text('Chart export'), submit => [ '#report_generator_form', { $key => "${value}report_generator_export_as_${type}" } ], ], action => [ @@ -92,6 +94,27 @@ sub action_report_generator_export_as_csv { print $::form->parse_html_template('report_generator/csv_export_options', { 'HIDDEN' => \@form_values }); } +sub action_report_generator_export_as_chart { + my ($self) = @_; + + delete $::form->{action_report_generator_export_as_chart}; + + if ($::form->{report_generator_chart_options_set}) { + $self->report_generator_do('Chart'); + return; + } + + my $fields = delete $::form->{report_generator_chart_fields}; + my @form_values = $::form->flatten_variables(grep { ($_ ne 'login') && ($_ ne 'password') } keys %{ $::form }); + + $::form->{title} = $::locale->text('Chart export -- options'); + + _setup_action_bar($self, 'chart'); # Sub not exported, therefore don't call via object. + + $::form->header; + print $::form->parse_html_template('report_generator/chart_export_options', { 'HIDDEN' => \@form_values, fields => $fields }); +} + sub action_report_generator_back { $_[0]->report_generator_do('HTML'); } diff --git a/SL/Controller/ImageUpload.pm b/SL/Controller/ImageUpload.pm index a2497f56b..4ecdbf4ad 100644 --- a/SL/Controller/ImageUpload.pm +++ b/SL/Controller/ImageUpload.pm @@ -20,7 +20,7 @@ my %object_loader = ( sales_order => [ "SL::DB::Order", [ sales => 1, quotation => 0 ] ], sales_quotation => [ "SL::DB::Order", [ sales => 1, quotation => 1 ] ], purchase_order => [ "SL::DB::Order", [ sales => 0, quotation => 1 ] ], - sales_delivery_order => [ "SL::DB::DeliveryOrder", [ order_type => 'sales_delivery_order' ] ], + sales_delivery_order => [ "SL::DB::DeliveryOrder", [ record_type => 'sales_delivery_order' ] ], ); diff --git a/SL/Controller/Inventory.pm b/SL/Controller/Inventory.pm index a4c11f6a0..6940b2eb5 100644 --- a/SL/Controller/Inventory.pm +++ b/SL/Controller/Inventory.pm @@ -574,7 +574,7 @@ sub action_save_stocktaking { stocktaking_cutoff_date => $::form->{cutoff_date_as_date}, }); 1; - } or do { $transfer_error = $EVAL_ERROR->error; } + } or do { $transfer_error = ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR; } }); return $self->js->flash('error', $transfer_error)->render() @@ -722,7 +722,7 @@ sub sanitize_target { my ($self) = @_; $self->warehouse($self->warehouses->[0]) if !$self->warehouse || !$self->warehouse->id; - $self->bin ($self->warehouse->bins->[0]) if !$self->bin || !$self->bin->id; + $self->bin ($self->warehouse->bins_sorted_naturally->[0]) if !$self->bin || !$self->bin->id; # foreach my $warehouse ( $self->warehouses ) { # $warehouse->{BINS} = []; # foreach my $bin ( $self->bins ) { @@ -768,7 +768,7 @@ sub build_warehouse_select { } sub build_bin_select { - select_tag('bin_id', [ $_[0]->warehouse->bins ], + select_tag('bin_id', $_[0]->warehouse->bins_sorted_naturally, title_key => 'description', default => $_[0]->bin->id, ); @@ -812,7 +812,7 @@ grouped_ids as ( from last_inventories group by trans_id order by max(itime) - desc limit 10 + desc limit 20 ) select unnest(ids) from grouped_ids @@ -867,8 +867,9 @@ sub prepare_stocktaking_report { my $callback = $self->stocktaking_models->get_callback; - my $report = SL::ReportGenerator->new(\%::myconfig, $::form); - $self->{report} = $report; + my $report = SL::ReportGenerator->new(\%::myconfig, $::form); + $report->{title} = t8('Stocktaking Journal'); + $self->{report} = $report; my @columns = qw(itime employee ean partnumber part qty unit bin chargenumber comment cutoff_date); my @sortable = qw(itime employee ean partnumber part qty bin chargenumber comment cutoff_date); diff --git a/SL/Controller/Invoice.pm b/SL/Controller/Invoice.pm new file mode 100644 index 000000000..58469a553 --- /dev/null +++ b/SL/Controller/Invoice.pm @@ -0,0 +1,160 @@ +package SL::Controller::Invoice; + +use strict; + +use parent qw(SL::Controller::Base); + +use Archive::Zip; +use Params::Validate qw(:all); + +use SL::DB::File; +use SL::DB::Invoice; +use SL::DB::Employee; + +use SL::Webdav; +use SL::File; +use SL::Locale::String qw(t8); +use SL::MoreCommon qw(listify); + +__PACKAGE__->run_before('check_auth'); + +sub check_auth { + my ($self) = validate_pos(@_, { isa => 'SL::Controller::Invoice' }, 1); + + return 1 if $::auth->assert('ar_transactions', 1); # may edit all invoices + my @ids = listify($::form->{id}); + $::auth->assert() unless has_rights_through_projects(\@ids); + return 1; +} + +sub has_rights_through_projects { + my ($ids) = validate_pos(@_, { + type => ARRAYREF, + }); + return 0 unless scalar @{$ids}; # creating new invoices isn't allowed without invoice_edit + my $current_employee = SL::DB::Manager::Employee->current; + my $id_placeholder = join(', ', ('?') x @{$ids}); + # Count of ids where the use has no access to + my $query = <client->dbh->selectrow_array($query, undef, $current_employee->id, @{$ids}); + return !$no_access_count; +} + +sub action_webdav_pdf_export { + my ($self) = @_; + my $ids = $::form->{id}; + + my $invoices = SL::DB::Manager::Invoice->get_all(where => [ id => $ids ]); + + my @file_names_and_file_paths; + my @errors; + foreach my $invoice (@{$invoices}) { + my $record_type = $invoice->record_type; + $record_type = 'invoice' if $record_type eq 'ar_transaction'; + $record_type = 'invoice' if $record_type eq 'invoice_storno'; + my $webdav = SL::Webdav->new( + type => $record_type, + number => $invoice->record_number, + ); + my @latest_object = $webdav->get_all_latest(); + unless (scalar @latest_object) { + push @errors, t8( + "No Dokument found for record '#1'. Please deselect it or create a document it.", + $invoice->displayable_name() + ); + next; + } + push @file_names_and_file_paths, { + file_name => $latest_object[0]->basename . "." . $latest_object[0]->extension, + file_path => $latest_object[0]->full_filedescriptor(), + } + } + + if (scalar @errors) { + die join("\n", @errors); + } + $self->_create_and_send_zip(\@file_names_and_file_paths); +} + +sub action_files_pdf_export { + my ($self) = @_; + + my $ids = $::form->{id}; + + my $invoices = SL::DB::Manager::Invoice->get_all(where => [ id => $ids ]); + + my @file_names_and_file_paths; + my @errors; + foreach my $invoice (@{$invoices}) { + my $record_type = $invoice->record_type; + $record_type = 'invoice' if $record_type eq 'ar_transaction'; + $record_type = 'invoice' if $record_type eq 'invoice_storno'; + my @file_objects = SL::File->get_all( + object_type => $record_type, + object_id => $invoice->id, + file_type => 'document', + source => 'created', + ); + + unless (scalar @file_objects) { + push @errors, t8( + "No Dokument found for record '#1'. Please deselect it or create a document it.", + $invoice->displayable_name() + ); + next; + } + foreach my $file_object (@file_objects) { + eval { + push @file_names_and_file_paths, { + file_name => $file_object->file_name, + file_path => $file_object->get_file(), + }; + } or do { + push @errors, $@, + }; + } + } + + if (scalar @errors) { + die join("\n", @errors); + } + $self->_create_and_send_zip(\@file_names_and_file_paths); +} + +sub _create_and_send_zip { + my ($self, $file_names_and_file_paths) = validate_pos(@_, + { isa => 'SL::Controller::Invoice' }, + { + type => ARRAYREF, + callbacks => { + "has 'file_name' and 'file_path'" => sub { + foreach my $file_entry (@{$_[0]}) { + return 0 unless defined $file_entry->{file_name} + && defined $file_entry->{file_path}; + } + return 1; + } + } + }); + + my ($fh, $zipfile) = File::Temp::tempfile(); + my $zip = Archive::Zip->new(); + foreach my $file (@{$file_names_and_file_paths}) { + $zip->addFile($file->{file_path}, $file->{file_name}); + } + $zip->writeToFileHandle($fh) == Archive::Zip::AZ_OK() or die 'error writing zip file'; + close($fh); + + $self->send_file( + $zipfile, + name => t8('pdf_records.zip'), unlink => 1, + type => 'application/zip', + ); +} + +1; diff --git a/SL/Controller/Letter.pm b/SL/Controller/Letter.pm index 4faf452cf..f7397c46b 100644 --- a/SL/Controller/Letter.pm +++ b/SL/Controller/Letter.pm @@ -160,16 +160,6 @@ sub action_delete { $self->redirect_to(action => 'list'); } -sub action_delete_letter_drafts { - my ($self, %params) = @_; - - my @ids = grep { /^checked_(.*)/ && $::form->{$_} } keys %$::form; - - SL::DB::Manager::LetterDraft->delete_all(query => [ ids => \@ids ]) if @ids; - - $self->redirect_to(action => 'add'); -} - sub action_list { my ($self, %params) = @_; @@ -249,15 +239,15 @@ sub action_print_letter { # set some form defaults for printing webdav copy variables if ( $::form->{media} eq 'email') { - my $mail = Mailer->new; - my $signature = $::myconfig{signature}; - $mail->{$_} = $params{email}->{$_} for qw(to cc subject message bcc); - $mail->{from} = qq|"$::myconfig{name}" <$::myconfig{email}>|; - $mail->{attachments} = [{ path => $result{file_name}, - name => $params{email}->{attachment_filename} }]; - $mail->{message} .= "\n-- \n$signature"; - $mail->{message} =~ s/\r//g; - $mail->{record_id} = $letter->id; + my $mail = Mailer->new; + $mail->{$_} = $params{email}->{$_} for qw(to cc subject message bcc); + $mail->{from} = qq|"$::myconfig{name}" <$::myconfig{email}>|; + $mail->{attachments} = [{ path => $result{file_name}, + name => $params{email}->{attachment_filename} }]; + $mail->{message} =~ s/\r//g; + $mail->{message} .= $::form->create_email_signature(); + $mail->{record_id} = $letter->id; + $mail->{content_type} = 'text/html'; $mail->send; unlink $result{file_name}; @@ -310,6 +300,7 @@ sub action_delete_drafts { my @ids = @{ $::form->{ids} || [] }; SL::DB::Manager::LetterDraft->delete_all(where => [ id => \@ids ]) if @ids; + flash('info', t8('Draft deleted')); $self->action_add(skip_drafts => 1); } @@ -325,7 +316,7 @@ sub action_send_email { sub _display { my ($self, %params) = @_; - $::request->{layout}->use_javascript("${_}.js") for qw(ckeditor/ckeditor ckeditor/adapters/jquery kivi.Letter kivi.SalesPurchase kivi.File); + $::request->{layout}->use_javascript("${_}.js") for qw(kivi.Letter kivi.SalesPurchase kivi.File); my $letter = $self->letter; @@ -370,8 +361,9 @@ sub _update { sub prepare_report { my ($self) = @_; - my $report = SL::ReportGenerator->new(\%::myconfig, $::form); - $self->{report} = $report; + my $report = SL::ReportGenerator->new(\%::myconfig, $::form); + $report->{title} = t8('Letters'); + $self->{report} = $report; my @columns = qw(date subject letternumber customer_id vendor_id contact date); my @sortable = qw(date subject letternumber customer_id vendor_id contact date); @@ -609,7 +601,7 @@ sub setup_load_letter_draft_action_bar { ], action => [ t8('Delete'), - submit => [ '#form', { action => 'delete_drafts' } ], + submit => [ '#form', { action => 'Letter/delete_drafts' } ], checks => [ [ 'kivi.check_if_entries_selected', '[name="ids[+]"]' ] ], confirm => t8('Do you really want to delete this draft?'), ], diff --git a/SL/Controller/LiquidityProjection.pm b/SL/Controller/LiquidityProjection.pm index 1c18f3420..9f5137d36 100644 --- a/SL/Controller/LiquidityProjection.pm +++ b/SL/Controller/LiquidityProjection.pm @@ -11,8 +11,7 @@ use SL::Util qw(_hashify); __PACKAGE__->run_before('check_auth'); use Rose::Object::MakeMethods::Generic ( - scalar => [ qw(liquidity) ], - 'scalar --get_set_init' => [ qw(oe_report_columns_str) ], + scalar => [ qw(liquidity) ], ); @@ -30,12 +29,28 @@ sub action_show { type => 1, salesman => 1, buchungsgruppe => 1, + parts_group => 1, }; $self->setup_show_action_bar; $self->render('liquidity_projection/show', title => t8('Liquidity projection')); } +sub action_list_orders { + my ($self) = @_; + + my @orders = SL::LiquidityProjection->orders_for_time_period( + after => $::form->{after} ? DateTime->from_kivitendo($::form->{after}) : undef, + before => $::form->{before} ? DateTime->from_kivitendo($::form->{before}) : undef, + ); + + $self->render( + 'liquidity_projection/list_orders', + title => t8('Sales Orders'), + ORDERS => \@orders, + ); +} + # # filters # @@ -53,30 +68,25 @@ sub link_to_old_orders { my $reqdate = $params{reqdate}; my $months = $params{months} * 1; + my $today = DateTime->today_local->truncate(to => 'month'); + my %url_params; my $fields = ''; if ($reqdate eq 'old') { - $fields .= '&reqdate_unset_or_old=Y'; + $url_params{before} = $today->to_kivitendo; } elsif ($reqdate eq 'future') { - my @now = localtime; - $fields .= '&reqdatefrom=' . $self->iso_to_display(SL::LiquidityProjection::_the_date($now[5] + 1900, $now[4] + 1 + $months) . '-01'); + $url_params{after} = $today->add(months => $months)->to_kivitendo; } else { - $reqdate =~ m/(\d+)-(\d+)/; - $fields .= '&reqdatefrom=' . $self->iso_to_display($reqdate . '-01'); - $fields .= '&reqdateto=' . $self->iso_to_display($reqdate . sprintf('-%02d', DateTime->last_day_of_month(year => $1, month => $2)->day)); - + $reqdate =~ m/(\d+)-(\d+)/; + my $date = DateTime->new_local(year => $1, month => $2, day => 1); + $url_params{after} = $date->to_kivitendo; + $url_params{before} = $date->add(months => 1)->to_kivitendo; } - return "oe.pl?action=orders&type=sales_order&vc=customer&" . $self->oe_report_columns_str . $fields; -} - -sub iso_to_display { - my ($self, $date) = @_; - - $::locale->reformat_date({ dateformat => 'yyyy-mm-dd' }, $date, $::myconfig{dateformat}); + return $self->url_for(action => 'list_orders', %url_params); } sub setup_show_action_bar { diff --git a/SL/Controller/ListTransactions.pm b/SL/Controller/ListTransactions.pm new file mode 100644 index 000000000..87df44ce2 --- /dev/null +++ b/SL/Controller/ListTransactions.pm @@ -0,0 +1,573 @@ +package SL::Controller::ListTransactions; + +use strict; +use parent qw(SL::Controller::Base); + +use POSIX qw(strftime); +use List::Util qw(first); +use Archive::Zip; + +use SL::DB::Chart; +use SL::DB::AccTransaction; +use SL::CA; + +use SL::ReportGenerator; +use SL::Controller::Helper::ReportGenerator; +use SL::Locale::String; +use SL::SessionFile::Random; +use SL::Helper::Flash qw(flash); +use SL::Presenter::EscapedText qw(escape); + +use SL::Presenter::DatePeriod qw(get_dialog_defaults_from_report_generator + populate_hidden_variables); + +use Rose::Object::MakeMethods::Generic ( + scalar => [ qw(defaults title account from_date to_date report_type report) ], + 'scalar --get_set_init' => [ qw(accounts_list) ], +); + +__PACKAGE__->run_before(sub { $::auth->assert('report'); }); + +# actions + +sub action_report_settings { + my ($self) = @_; + + $self->set_defaults; + + # if we're coming from a linked entry we want to pre-select the chart picker + if ($::form->{link}) { + my $account = first { $_->{accno} eq $::form->{accno} } @{ $self->accounts_list }; + $self->defaults->{chart_id} = $account->{chart_id}; + } + + $self->setup_report_settings_action_bar; + $self->render('list_transactions/report_settings', + title => t8('List Transactions'), + accounts_list => $self->accounts_list, + defaults => $self->defaults, + ); +} + +sub action_list { + my ($self) = @_; + + $self->set_dates; + + $self->report_type('HTML'); + + # set account number from chart picker chart_id + my $account = first { $_->{chart_id} eq $::form->{chart_id} } @{ $self->accounts_list }; + if (!$account) { + flash('error', t8('No account selected. Please select an account.')); + return $self->action_report_settings; + } + $::form->{accno} = $account->{accno}; + + $self->set_title; + + $self->setup_list_action_bar; + $self->prepare_report; + $self->set_report_data; + $self->report->generate_with_headers; +} + +sub action_export_options_all_charts { + my ($self) = @_; + + $self->set_dates; + + # misusing the set_defaults function here a bit to easily + # get the values from the form, + # we have to get the values here because we have to forward them + # to the csv options form using a hidden array + $self->set_defaults; + + $self->defaults->{fromdate} = $self->from_date; + $self->defaults->{todate} = $self->to_date; + + # dialog state is returned in a nested hash, this has to be flattened here + my @hidden; + push @hidden, map { + { key => 'dateperiod_selected_preset_' . $_, value => $self->defaults->{dialog}->{$_} } + } keys %{ $self->defaults->{dialog} }; + delete($self->defaults->{dialog}); + + # handle the rest of the state + push @hidden, map { + { key => $_, value => $self->defaults->{$_} } + } keys %{ $self->defaults }; + + if ($::form->{output_format} eq 'PDF') { + $self->setup_export_options_action_bar(output_format => 'PDF'); + $self->render('report_generator/pdf_export_options', + title => t8('PDF export -- options'), + HIDDEN => \@hidden, + ); + } else { + $self->setup_export_options_action_bar(output_format => 'CSV'); + $self->render('report_generator/csv_export_options', + title => t8('CSV export -- options'), + HIDDEN => \@hidden, + ); + } +} + +sub action_export_all_charts { + my ($self) = @_; + + my $output_format = $::form->{output_format} // 'CSV'; + + my $zip = Archive::Zip->new(); + + for my $account (@{ $self->accounts_list }) { + next if $account->{charttype} eq "H" || !defined($account->{balance}); + + $::form->{accno} = $account->{accno}; + + my $sfile = SL::SessionFile::Random->new(mode => "w"); + + $self->set_title; + $self->report_type($output_format); + + $self->prepare_report; + $self->set_report_data; + if ($output_format eq 'PDF') { + my $output = $self->report->generate_pdf_content(want_binary_pdf => 1); + $sfile->fh->print($output); + } else { + $self->report->_generate_csv_content($sfile->fh); + } + $sfile->fh->close; + + # we need to sanitize the account number before using it in the filename + # to prevent unexpected outcomes due to slashes etc. + my $sanitized_accno = $account->{accno} =~ s/[^A-Za-z0-9\-\.\_\ ]/_/gr; + + $zip->addFile( + $sfile->{file_name}, + t8('list_of_transactions') . "_" . t8('account') . "_" . $sanitized_accno . ($output_format eq 'PDF' ? '.pdf' : '.csv') + ); + } + + my $zipfile = SL::SessionFile::Random->new(mode => "w"); + unless ( $zip->writeToFileNamed($zipfile->file_name) == Archive::Zip::AZ_OK ) { + die 'zipfile write error'; + } + $zipfile->fh->close; + + $self->send_file( + $zipfile->file_name, + type => 'application/zip', + name => t8('list_of_transactions') . strftime('_%Y%m%d', localtime time) . '.zip', + ); +} + +# local functions + +sub set_defaults { + my ($self) = @_; + + # use values from form, then report generator form, then fallback + my %fallback = ( + #accno => $self->accounts_list->[0]->{accno}, + chart_id => '', + reporttype => 'custom', + year => DateTime->today->year, + duetyp => '13', + dateperiod_from_date => '', + dateperiod_to_date => '', + show_subtotals => 0, + sort => 'transdate', + ); + my %defaults; + for (keys %fallback) { + $defaults{$_} = $::form->{$_} // $::form->{'report_generator_hidden_' . $_} // $fallback{$_}; + } + + $defaults{dialog} = get_dialog_defaults_from_report_generator('dateperiod'); + + $self->defaults(\%defaults); +} + +sub set_title { + my ($self) = @_; + my $account = first { $_->{accno} eq $::form->{accno} } @{ $self->accounts_list }; + $self->title(escape(join(" ", t8('List Transactions'), t8('Account'), $account->{text}))); +} + +sub set_dates { + my ($self) = @_; + + # set dates according to selection + $self->from_date($::form->{dateperiod_from_date}); + $self->to_date($::form->{dateperiod_to_date}); + + # set this into form here for the CA-> routines + $::form->{fromdate} = $self->from_date; + $::form->{todate} = $self->to_date; + # (no further checks needed, a reasonable error is shown when dates are invalid) +} + +sub prepare_report { + my ($self) = @_; + + $self->report(SL::ReportGenerator->new(\%::myconfig, $::form)); + + my @columns = qw(transdate reference description gegenkonto debit credit ustkonto ustrate balance); + my %column_defs = ( + transdate => { text => t8('Date'), }, + reference => { text => t8('Reference'), }, + description => { text => t8('Description'), }, + debit => { text => t8('Debit'), }, + credit => { text => t8('Credit'), }, + gegenkonto => { text => t8('Gegenkonto'), }, + ustkonto => { text => t8('USt-Konto'), }, + balance => { text => t8('Balance'), }, + ustrate => { text => t8('Satz %'), }, + ); + + $self->report->set_options( + std_column_visibility => 1, + controller_class => 'ListTransactions', + output_format => $self->report_type, + title => $self->title, + allow_pdf_export => 1, + allow_csv_export => 1, + allow_chart_export => 0, + attachment_basename => t8('list_of_transactions') . strftime('_%Y%m%d', localtime time), + top_info_text => $self->get_top_info_text, + ); + $self->report->set_columns(%column_defs); + $self->report->set_column_order(@columns); + + my @hidden_variables = qw(accno chart_id show_subtotals sort); + populate_hidden_variables('dateperiod', \@hidden_variables); + + $self->report->set_export_options(qw(list), @hidden_variables); + $self->report->set_options_from_form; + $self->report->set_sort_indicator($::form->{sort}, 1); + # this is getting triggered but doesn't seem to have an effect + #$::locale->set_numberformat_wo_thousands_separator(\%::myconfig) if lc($self->report->{options}->{output_format}) eq 'csv'; +} + +sub set_report_data { + my ($self) = @_; + + CA->all_transactions(\%::myconfig, \%$::form); + + # this data is used in custom header + $self->{eb_value} = $::form->{beginning_balance}; + $self->{saldo_old} = $::form->{saldo_old} + $::form->{beginning_balance}; + # "Jahresverkehrszahlen alt" + $self->{debit_old} = $::form->{old_balance_debit}; + $self->{credit_old} = $::form->{old_balance_credit}; + + $self->set_report_custom_headers(); + + # initialise totals + $self->{total_debit} = 0.; + $self->{total_credit} = 0.; + my $subtotal_debit = 0.; + my $subtotal_credit = 0.; + $self->{balance} = $self->{saldo_old}; + + # used for subtotals below + my $sort_key = $::form->{sort}; + + my $idx = 0; + for my $tr (@{ $::form->{CA} }) { + + # sum up totals + $self->{total_debit} += $tr->{debit}; + $self->{total_credit} += $tr->{credit}; + $subtotal_debit += $tr->{debit}; + $subtotal_credit += $tr->{credit}; + $self->{balance} -= $tr->{debit}; + $self->{balance} += $tr->{credit}; + + # formatting + my $credit = $tr->{credit} ? $::form->format_amount(\%::myconfig, $tr->{credit}, 2) : '0'; + my $debit = $tr->{debit} ? $::form->format_amount(\%::myconfig, $tr->{debit}, 2) : '0'; + my $ustrate = ''; + if ($tr->{ustrate}) { + # only format to decimal point when not zero (analog to previous behavior in ca.pl) + $ustrate = $tr->{ustrate} != 0 ? $::form->format_amount(\%::myconfig, $tr->{ustrate} * 100, 2) : '0'; + } + + my $gegenkonto_string = ""; + foreach my $gegenkonto (@{ $tr->{GEGENKONTO} }) { + if ($gegenkonto_string eq "") { + $gegenkonto_string = $gegenkonto->{accno}; + } else { + $gegenkonto_string .= ", " . $gegenkonto->{accno}; + } + } + + my $reference_link = "$tr->{module}.pl?action=edit&id=$tr->{id}"; + + my %data = ( + transdate => { data => $tr->{transdate}, }, + reference => { data => $tr->{reference}, link => $reference_link }, + description => { data => $tr->{description}, }, + gegenkonto => { data => $gegenkonto_string, }, + debit => { data => $debit }, + credit => { data => $credit }, + ustkonto => { data => $tr->{ustkonto}, }, + ustrate => { data => $ustrate }, + balance => { data => $::form->format_amount(\%::myconfig, $self->{balance}, 2, 'DRCR') }, + ); + $data{$_}->{align} = 'right' for qw(debit credit ustkonto ustrate balance); + # use a row set here in order to keep the table coloring intact + my @row_set; + push @row_set, \%data; + + # show subtotals if setting enabled and ( last element reached or + # next element has a different value in the field selected by sort key ) + if ( ($::form->{show_subtotals}) && + ( ($idx == scalar @{ $::form->{CA} } - 1) || + ($tr->{$sort_key} ne $::form->{CA}->[$idx + 1]->{$sort_key}) ) ) { + + my %data = map { $_ => { class => 'listtotal' } } keys %{ $self->report->{columns} }; + $data{credit}->{data} = $::form->format_amount(\%::myconfig, $subtotal_credit, 2); + $data{debit}->{data} = $::form->format_amount(\%::myconfig, $subtotal_debit, 2); + $data{$_}->{align} = 'right' for qw(debit credit); + push @row_set, \%data; + + $subtotal_credit = 0.; + $subtotal_debit = 0.; + } + $self->report->add_data(\@row_set); + $idx++; + } + + # debit credit and balance totals line + my %data = map { $_ => { class => 'listtotal' } } keys %{ $self->report->{columns} }; + $data{credit}->{data} = $::form->format_amount(\%::myconfig, $self->{total_credit}, 2); + $data{debit}->{data} = $::form->format_amount(\%::myconfig, $self->{total_debit}, 2); + $data{balance}->{data} = $::form->format_amount(\%::myconfig, $self->{balance}, 2, 'DRCR'); + $data{$_}->{align} = 'right' for qw(debit credit balance); + $self->report->add_data(\%data); + + # get data for the footer line from the CA->all_transactions request + $self->{saldo_new} = $::form->{saldo_new} + $::form->{beginning_balance}; + # "Jahresverkehrszahlen neu" + $self->{debit_new} = $::form->{current_balance_debit}; + $self->{credit_new} = $::form->{current_balance_credit}; + + $self->set_report_footer_lines(); +} + +sub set_report_footer_lines { + my ($self) = @_; + # line 1 + my %data = map { $_ => { class => 'listtotal' } } keys %{ $self->report->{columns} }; + $data{reference}->{data} = t8('EB-Wert'); + $data{description} = { data => t8('Saldo neu'), class => 'listtotal', colspan => 2 }; + $data{debit} = { data => t8('Jahresverkehrszahlen neu'), class => 'listtotal', colspan => 2 }; + $self->report->add_data(\%data); + + # line 2 + my %data2 = map { $_ => { class => 'listtotal' } } keys %{ $self->report->{columns} }; + $data2{reference}->{data} = format_debit_credit($self->{eb_value}); + $data2{description} = { data => format_debit_credit($self->{saldo_new}), class => 'listtotal', colspan => 2 }; + $data2{debit}->{data} = $::form->format_amount(\%::myconfig, abs($self->{debit_new}) , 2) . " S"; + $data2{credit}->{data} = $::form->format_amount(\%::myconfig, $self->{credit_new}, 2) . " H"; + $self->report->add_data(\%data2); +} + +sub set_report_custom_headers { + my ($self) = @_; + + my @custom_headers = (); + # line 1 + push @custom_headers, [ + { text => t8('Letzte Buchung'), }, + { text => t8('EB-Wert'), }, + { text => t8('Saldo alt'), 'colspan' => 2, }, + { text => t8('Jahresverkehrszahlen alt'), 'colspan' => 2, }, + { text => '', 'colspan' => 2, }, + ]; + push @custom_headers, [ + { text => $::form->{last_transaction}, }, + { text => format_debit_credit($self->{eb_value}), }, + { text => format_debit_credit($self->{saldo_old}), 'colspan' => 2, }, + { text => $::form->format_amount(\%::myconfig, abs($self->{debit_old}), 2) . " S", }, + { text => $::form->format_amount(\%::myconfig, $self->{credit_old}, 2) . " H", }, + { text => '', 'colspan' => 2, }, + ]; + # line 2 + # sorting is selected with radio button + #my $link = "controller.pl?action=ListTransactions%2freport_settings&accno=$::form->{accno}&fromdate=$::form->{fromdate}&todate=$::form->{todate}&show_subtotals=$::form->{show_subtotals}"; + push @custom_headers, [ + { text => t8('Date'), }, # link => $link . "&sort=transdate", }, + { text => t8('Reference'), }, #'link' => $link . "&sort=reference", }, + { text => t8('Description'), }, #'link' => $link . "&sort=description", }, + { text => t8('Gegenkonto'), }, + { text => t8('Debit'), }, + { text => t8('Credit'), }, + { text => t8('USt-Konto'), }, + { text => t8('Satz %'), }, + { text => t8('Balance'), }, + ]; + + $self->report->set_custom_headers(@custom_headers); +} + +# action bar + +sub setup_report_settings_action_bar { + my ($self, %params) = @_; + + for my $bar ($::request->layout->get('actionbar')) { + $bar->add( + action => [ + t8('Show'), + submit => [ '#report_settings', { action => 'ListTransactions/list' } ], + accesskey => 'enter', + ], + combobox => [ + action => [ + t8('Export'), + ], + action => [ + t8('Export all accounts to CSV (ZIP file)'), + submit => [ '#report_settings', { + action => 'ListTransactions/export_options_all_charts', + output_format => 'CSV', + } ], + ], + action => [ + t8('Export all accounts to PDF (ZIP file)'), + submit => [ '#report_settings', { + action => 'ListTransactions/export_options_all_charts', + output_format => 'PDF', + } ], + ], + ], # end of combobox "Export" + ); + } +} + +sub setup_export_options_action_bar { + my ($self, %params) = @_; + for my $bar ($::request->layout->get('actionbar')) { + $bar->add( + action => [ + t8('Export'), + submit => [ '#report_generator_form', { + action => 'ListTransactions/export_all_charts', + output_format => $params{output_format}, + } ], + accesskey => 'enter', + ], + action => [ + t8('Back'), + submit => [ '#report_generator_form', { action => 'ListTransactions/report_settings' } ], + ], + ); + } +} + +sub setup_list_action_bar { + my ($self, %params) = @_; + for my $bar ($::request->layout->get('actionbar')) { + $bar->add( + action => [ + t8('Back'), + submit => [ '#report_generator_form', { action => 'ListTransactions/report_settings' } ], + ], + ); + } +} + +# helper + +sub get_top_info_text { + my ($self) = @_; + my @text; + if ($::form->{department}) { + my ($department) = split /--/, $::form->{department}; + push @text, $::locale->text('Department') . " : $department"; + } + if ($::form->{projectnumber}) { + push @text, $::locale->text('Project Number') . " : $::form->{projectnumber}
"; + } + push @text, join " ", t8('Period:'), $::form->{fromdate}, t8('to'), $::form->{todate}; + push @text, join " ", t8('Report date:'), $::locale->format_date_object(DateTime->now_local); + push @text, join " ", t8('Company:'), $::instance_conf->get_company; + join "\n", @text; +} + +sub format_debit_credit { + my $dc = shift; + my $formatted_dc = $::form->format_amount(\%::myconfig, abs($dc), 2) . ' '; + $formatted_dc .= ($dc > 0) ? t8('Credit (one letter abbreviation)') : t8('Debit (one letter abbreviation)'); + $formatted_dc; +} + +sub init_accounts_list { + CA->all_accounts(\%::myconfig, \%$::form); + my @accounts_list = map { { + text => "$_->{accno} - $_->{description}", + accno => $_->{accno}, + chart_id => $_->{id}, + balance => $_->{amount}, + charttype => $_->{charttype}, + } } @{ $::form->{CA} }; + \@accounts_list; +} + +1; + +__END__ + +=encoding utf-8 + +=head1 NAME + +SL::Controller::ListTransactions - Controller for the ListTransactions report + +=head1 SYNOPSIS + +New controller for Reports -> ListTransactions. + +This replaces the functions from bin/mozilla/ca.pl. + +The chart_of_accounts functionality is implemented separately in +SL::Controller::ChartOfAccounts. + +=head1 DESCRIPTION / Key Features + +A form is shown to select the accounts and the date period, as well as +options and the sorting of the report. + +At this point, exporting all accounts is possible via Export -> Export all +accounts to CSV / PDF (ZIP file). + +This will export all accounts for the selected time period and options, +and offer the resulting file for download. + +The date period selection makes use of a new presenter SL::Presenter::DatePeriod. + +If no date is selected all transactions are shown. + +The resulting report should be equivalent to the old behavior, except +for the sorting, that has to be selected in advance now. + +=head1 CAVEATS / TODO + +Database queries are still from SL::CA. + +The database queries in SL::CA are quite sophisticated, therefore i'm still using +these for now. + +=head1 BUGS + +None yet. + +=head1 AUTHOR + +Cem Aydin Ecem.aydin@revamp-it.chE + +=cut diff --git a/SL/Controller/Order.pm b/SL/Controller/Order.pm index faf2ffaac..79c031021 100644 --- a/SL/Controller/Order.pm +++ b/SL/Controller/Order.pm @@ -3,11 +3,12 @@ package SL::Controller::Order; use strict; use parent qw(SL::Controller::Base); -use SL::Helper::Flash qw(flash_later); +use SL::Helper::Flash qw(flash flash_later); use SL::HTML::Util; use SL::Presenter::Tag qw(select_tag hidden_tag div_tag); use SL::Locale::String qw(t8); use SL::SessionFile::Random; +use SL::IMAPClient; use SL::PriceSource; use SL::Webdav; use SL::File; @@ -18,6 +19,7 @@ use SL::DB::AdditionalBillingAddress; use SL::DB::AuthUser; use SL::DB::History; use SL::DB::Order; +use SL::DB::OrderItem; use SL::DB::Default; use SL::DB::Unit; use SL::DB::Part; @@ -26,10 +28,19 @@ use SL::DB::PartsGroup; use SL::DB::Printer; use SL::DB::Note; use SL::DB::Language; +use SL::DB::Reclamation; use SL::DB::RecordLink; -use SL::DB::RequirementSpec; use SL::DB::Shipto; use SL::DB::Translation; +use SL::DB::EmailJournal; +use SL::DB::ValidityToken; +use SL::DB::Helper::RecordLink qw(set_record_link_conversions RECORD_ID RECORD_TYPE_REF RECORD_ITEM_ID RECORD_ITEM_TYPE_REF); +use SL::DB::Helper::TypeDataProxy; +use SL::DB::Helper::Record qw(get_object_name_from_type get_class_from_type); +use SL::Model::Record; +use SL::DB::Order::TypeData qw(:types); +use SL::DB::DeliveryOrder::TypeData qw(:types); +use SL::DB::Reclamation::TypeData qw(:types); use SL::Helper::CreatePDF qw(:all); use SL::Helper::PrintOptions; @@ -37,12 +48,13 @@ use SL::Helper::ShippedQty; use SL::Helper::UserPreferences::DisplayPreferences; use SL::Helper::UserPreferences::PositionsScrollbar; use SL::Helper::UserPreferences::UpdatePositions; +use SL::Helper::UserPreferences::ItemInputPosition; use SL::Controller::Helper::GetModels; use List::Util qw(first sum0); use List::UtilsBy qw(sort_by uniq_by); -use List::MoreUtils qw(any none pairwise first_index); +use List::MoreUtils qw(uniq any none pairwise first_index); use English qw(-no_match_vars); use File::Spec; use Cwd; @@ -51,23 +63,21 @@ use Sort::Naturally; use Rose::Object::MakeMethods::Generic ( scalar => [ qw(item_ids_to_delete is_custom_shipto_to_delete) ], - 'scalar --get_set_init' => [ qw(order valid_types type cv p all_price_factors search_cvpartnumber show_update_button part_picker_classification_ids) ], + 'scalar --get_set_init' => [ qw(order valid_types type cv p all_price_factors + search_cvpartnumber show_update_button + part_picker_classification_ids + is_final_version type_data) ], ); # safety -__PACKAGE__->run_before('check_auth'); +__PACKAGE__->run_before('check_auth', + except => [ qw(close_quotations) ]); __PACKAGE__->run_before('check_auth_for_edit', - except => [ qw(edit show_customer_vendor_details_dialog price_popup load_second_rows) ]); - -__PACKAGE__->run_before('recalc', - only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_invoice_for_advance_payment save_and_final_invoice save_and_ap_transaction - print send_email) ]); - -__PACKAGE__->run_before('get_unalterable_data', - only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_invoice_for_advance_payment save_and_final_invoice save_and_ap_transaction - print send_email) ]); + except => [ qw(edit price_popup load_second_rows close_quotations) ]); +__PACKAGE__->run_before('get_basket_info_from_from', + except => [ qw(close_quotations) ]); # # actions @@ -77,50 +87,104 @@ __PACKAGE__->run_before('get_unalterable_data', sub action_add { my ($self) = @_; - $self->order->transdate(DateTime->now_local()); - my $extra_days = $self->type eq sales_quotation_type() ? $::instance_conf->get_reqdate_interval : - $self->type eq sales_order_type() ? $::instance_conf->get_delivery_date_interval : 1; + $self->pre_render(); - if ( ($self->type eq sales_order_type() && $::instance_conf->get_deliverydate_on) - || ($self->type eq sales_quotation_type() && $::instance_conf->get_reqdate_on) - && (!$self->order->reqdate)) { - $self->order->reqdate(DateTime->today_local->next_workday(extra_days => $extra_days)); + if (!$::form->{form_validity_token}) { + $::form->{form_validity_token} = SL::DB::ValidityToken->create(scope => SL::DB::ValidityToken::SCOPE_ORDER_SAVE())->token; } - - $self->pre_render(); $self->render( 'order/form', - title => $self->get_title_for('add'), + title => $self->type_data->text('add'), %{$self->{template_args}} ); } +sub action_add_from_record { + my ($self) = @_; + my $from_type = $::form->{from_type}; + my $from_id = $::form->{from_id}; + + die "No 'from_type' was given." unless ($from_type); + die "No 'from_id' was given." unless ($from_id); + + my %flags = (); + if (defined($::form->{from_item_ids})) { + my %use_item = map { $_ => 1 } @{$::form->{from_item_ids}}; + $flags{item_filter} = sub { + my ($item) = @_; + return %use_item{$item->{RECORD_ITEM_ID()}}; + } + } + + my $record = SL::Model::Record->get_record($from_type, $from_id); + my $order = SL::Model::Record->new_from_workflow($record, $self->type, %flags); + $self->order($order); + + $self->reinit_after_new_order(); + + $self->action_add(); +} + +sub action_add_from_purchase_basket { + my ($self) = @_; + + my $basket_item_ids = $::form->{basket_item_ids} || []; + my $vendor_item_ids = $::form->{vendor_item_ids} || []; + my $vendor_id = $::form->{vendor_id}; + + + unless (scalar @{ $basket_item_ids} || scalar @{ $vendor_item_ids}) { + $self->js->flash('error', t8('There are no items selected')); + return $self->js->render(); + } + + my $order = SL::DB::Order->create_from_purchase_basket( + $basket_item_ids, $vendor_item_ids, $vendor_id + ); + + $self->order($order); + + $self->reinit_after_new_order(); + + $self->action_add(); +} + +sub action_add_from_email_journal { + my ($self) = @_; + die "No 'email_journal_id' was given." unless ($::form->{email_journal_id}); + + $self->action_add(); +} + +sub action_edit_with_email_journal_workflow { + my ($self) = @_; + die "No 'email_journal_id' was given." unless ($::form->{email_journal_id}); + $::form->{workflow_email_journal_id} = delete $::form->{email_journal_id}; + $::form->{workflow_email_attachment_id} = delete $::form->{email_attachment_id}; + $::form->{workflow_email_callback} = delete $::form->{callback}; + + $self->action_edit(); +} + # edit an existing order sub action_edit { my ($self) = @_; + die "No 'id' was given." unless $::form->{id}; - if ($::form->{id}) { - $self->load_order; - - } else { - # this is to edit an order from an unsaved order object + $self->load_order; - # set item ids to new fake id, to identify them as new items - foreach my $item (@{$self->order->items_sorted}) { - $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000); + if ($self->order->is_sales && $::lx_office_conf{imap_client}->{enabled}) { + my $imap_client = SL::IMAPClient->new(%{$::lx_office_conf{imap_client}}); + if ($imap_client) { + $imap_client->update_email_files_for_record(record => $self->order); } - # trigger rendering values for second row as hidden, because they - # are loaded only on demand. So we need to keep the values from - # the source. - $_->{render_second_row} = 1 for @{ $self->order->items_sorted }; } - $self->recalc(); $self->pre_render(); $self->render( 'order/form', - title => $self->get_title_for('edit'), + title => $self->type_data->text('edit'), %{$self->{template_args}} ); } @@ -149,27 +213,26 @@ sub action_edit_collective { # make new order from given orders my @multi_orders = map { SL::DB::Order->new(id => $_)->load } @multi_ids; - $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders; - $self->order(SL::DB::Order->new_from_multi(\@multi_orders, sort_sources_by => 'transdate')); + my $target_type = SALES_ORDER_TYPE(); + my $order = SL::Model::Record->new_from_workflow_multi(\@multi_orders, $target_type, sort_sources_by => 'transdate'); + $self->order($order); + $self->reinit_after_new_order(); - $self->action_edit(); + $self->action_add(); } # delete the order sub action_delete { my ($self) = @_; - my $errors = $self->delete(); - - if (scalar @{ $errors }) { - $self->js->flash('error', $_) foreach @{ $errors }; - return $self->js->render(); - } - - my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been deleted') - : $self->type eq purchase_order_type() ? $::locale->text('The order has been deleted') - : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been deleted') - : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been deleted') + SL::Model::Record->delete($self->order); + my $text = $self->type eq SALES_ORDER_INTAKE_TYPE() ? $::locale->text('The order intake has been deleted') + : $self->type eq SALES_ORDER_TYPE() ? $::locale->text('The order confirmation has been deleted') + : $self->type eq PURCHASE_ORDER_TYPE() ? $::locale->text('The order has been deleted') + : $self->type eq PURCHASE_ORDER_CONFIRMATION_TYPE() ? $::locale->text('The order confirmation has been deleted') + : $self->type eq SALES_QUOTATION_TYPE() ? $::locale->text('The quotation has been deleted') + : $self->type eq REQUEST_QUOTATION_TYPE() ? $::locale->text('The rfq has been deleted') + : $self->type eq PURCHASE_QUOTATION_INTAKE_TYPE() ? $::locale->text('The quotation intake has been deleted') : ''; flash_later('info', $text); @@ -185,19 +248,9 @@ sub action_delete { sub action_save { my ($self) = @_; - my $errors = $self->save(); + $self->save(); - if (scalar @{ $errors }) { - $self->js->flash('error', $_) foreach @{ $errors }; - return $self->js->render(); - } - - my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved') - : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved') - : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved') - : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved') - : ''; - flash_later('info', $text); + flash_later('info', $self->type_data->text('saved')); my @redirect_params; if ($::form->{back_to_caller}) { @@ -216,7 +269,25 @@ sub action_save { $self->redirect_to(@redirect_params); } -# save the order as new document an open it for edit +# create new version and set version number +sub action_add_subversion { + my ($self) = @_; + + SL::DB->client->with_transaction( + sub { + SL::Model::Record->increment_subversion($self->order); + $self->save(); + 1; + } + ); + + $self->redirect_to(action => 'edit', + type => $self->type, + id => $self->order->id, + ); +} + +# save the order as new document and open it for edit sub action_save_as_new { my ($self) = @_; @@ -227,48 +298,25 @@ sub action_save_as_new { return $self->js->render(); } - # load order from db to check if values changed my $saved_order = SL::DB::Order->new(id => $order->id)->load; - my %new_attrs; - # Lets assign a new number if the user hasn't changed the previous one. - # If it has been changed manually then use it as-is. - $new_attrs{number} = (trim($order->number) eq $saved_order->number) - ? '' - : trim($order->number); - - # Clear transdate unless changed - $new_attrs{transdate} = ($order->transdate == $saved_order->transdate) - ? DateTime->today_local - : $order->transdate; - - # Set new reqdate unless changed if it is enabled in client config - if ($order->reqdate == $saved_order->reqdate) { - my $extra_days = $self->type eq sales_quotation_type() ? $::instance_conf->get_reqdate_interval : - $self->type eq sales_order_type() ? $::instance_conf->get_delivery_date_interval : 1; - - if ( ($self->type eq sales_order_type() && !$::instance_conf->get_deliverydate_on) - || ($self->type eq sales_quotation_type() && !$::instance_conf->get_reqdate_on)) { - $new_attrs{reqdate} = ''; - } else { - $new_attrs{reqdate} = DateTime->today_local->next_workday(extra_days => $extra_days); - } - } else { - $new_attrs{reqdate} = $order->reqdate; - } - - # Update employee - $new_attrs{employee} = SL::DB::Manager::Employee->current; + # Create new record from current one + my $new_order = SL::Model::Record->clone_for_save_as_new($saved_order, $order); + $self->order($new_order); # Warn on obsolete items - my @obsolete_positions = map { $_->position } grep { $_->part->obsolete } @{ $order->items_sorted }; - flash_later('warning', t8('This record containts obsolete items at position #1', join ', ', @obsolete_positions)) if @obsolete_positions; + my @obsolete_positions = map { $_->position } grep { $_->part->obsolete } @{ $self->order->items_sorted }; + flash_later('warning', t8('This record contains obsolete items at position #1', join ', ', @obsolete_positions)) if @obsolete_positions; - # Create new record from current one - $self->order(SL::DB::Order->new_from($order, destination_type => $order->type, attributes => \%new_attrs)); + # Warn on order locked items if they are not wanted for this record type + if ($self->type_data->no_order_locked_parts) { + my @order_locked_positions = map { $_->position } grep { $_->part->order_locked } @{ $self->order->items_sorted }; + flash_later('warning', t8('This record contains not orderable items at position #1', join ', ', @order_locked_positions)) if @order_locked_positions; + } - # no linked records on save as new - delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids); + if (!$::form->{form_validity_token}) { + $::form->{form_validity_token} = SL::DB::ValidityToken->create(scope => SL::DB::ValidityToken::SCOPE_ORDER_SAVE())->token; + } # save $self->action_save(); @@ -282,12 +330,7 @@ sub action_save_as_new { sub action_print { my ($self) = @_; - my $errors = $self->save(); - - if (scalar @{ $errors }) { - $self->js->flash('error', $_) foreach @{ $errors }; - return $self->js->render(); - } + $self->save(); $self->js_reset_order_and_item_ids_after_save; @@ -371,11 +414,7 @@ sub action_print { sub action_preview_pdf { my ($self) = @_; - my $errors = $self->save(); - if (scalar @{ $errors }) { - $self->js->flash('error', $_) foreach @{ $errors }; - return $self->js->render(); - } + $self->save(); $self->js_reset_order_and_item_ids_after_save; @@ -429,39 +468,36 @@ sub action_preview_pdf { sub action_save_and_show_email_dialog { my ($self) = @_; - my $errors = $self->save(); - - if (scalar @{ $errors }) { - $self->js->flash('error', $_) foreach @{ $errors }; - return $self->js->render(); + if (!$self->is_final_version) { + $self->save(); + $self->js_reset_order_and_item_ids_after_save; } - $self->js_reset_order_and_item_ids_after_save; - - my $cv_method = $self->cv; + my $cv = $self->order->customervendor + or return $self->js->flash('error', + $self->type_data->properties('is_customer') ? + t8('Cannot send E-mail without customer given') + : t8('Cannot send E-mail without vendor given') + )->render($self); - if (!$self->order->$cv_method) { - return $self->js->flash('error', $self->cv eq 'customer' ? t8('Cannot send E-mail without customer given') : t8('Cannot send E-mail without vendor given')) - ->render($self); - } + my $form = Form->new; + $form->{$self->nr_key()} = $self->order->number; + $form->{cusordnumber} = $self->order->cusordnumber; + $form->{formname} = $self->type; + $form->{type} = $self->type; + $form->{language} = '_' . $self->order->language->template_code if $self->order->language; + $form->{language_id} = $self->order->language->id if $self->order->language; + $form->{format} = 'pdf'; + $form->{cp_id} = $self->order->contact->cp_id if $self->order->contact; + $form->{transaction_description} = $self->order->transaction_description; my $email_form; - $email_form->{to} = $self->order->contact->cp_email if $self->order->contact; - $email_form->{to} ||= $self->order->$cv_method->email; - $email_form->{cc} = $self->order->$cv_method->cc; - $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc; + $email_form->{to} = + ($self->order->contact ? $self->order->contact->cp_email : undef) + || $cv->email; + $email_form->{cc} = $cv->cc; + $email_form->{bcc} = join ', ', grep $_, $cv->bcc; # Todo: get addresses from shipto, if any - - my $form = Form->new; - $form->{$self->nr_key()} = $self->order->number; - $form->{cusordnumber} = $self->order->cusordnumber; - $form->{formname} = $self->type; - $form->{type} = $self->type; - $form->{language} = '_' . $self->order->language->template_code if $self->order->language; - $form->{language_id} = $self->order->language->id if $self->order->language; - $form->{format} = 'pdf'; - $form->{cp_id} = $self->order->contact->cp_id if $self->order->contact; - $email_form->{subject} = $form->generate_email_subject(); $email_form->{attachment_filename} = $form->generate_attachment_filename(); $email_form->{message} = $form->generate_email_body(); @@ -474,40 +510,56 @@ sub action_save_and_show_email_dialog { $user && !!trim($user->get_config_value('email')); } @{ SL::DB::Manager::Employee->get_all_sorted(query => [ deleted => 0 ]) }; - - my $all_partner_email_addresses = $self->order->customervendor->get_all_email_addresses(); - - my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 }, - email_form => $email_form, - show_bcc => $::auth->assert('email_bcc', 'may fail'), - FILES => \%files, - is_customer => $self->cv eq 'customer', - ALL_EMPLOYEES => \@employees_with_email, - ALL_PARTNER_EMAIL_ADDRESSES => $all_partner_email_addresses, + my $dialog_html = $self->render( + 'common/_send_email_dialog', { output => 0 }, + email_form => $email_form, + show_bcc => $::auth->assert('email_bcc', 'may fail'), + FILES => \%files, + is_customer => $self->type_data->properties('is_customer'), + ALL_EMPLOYEES => \@employees_with_email, + ALL_PARTNER_EMAIL_ADDRESSES => $cv->get_all_email_addresses(), + is_final_version => $self->is_final_version, ); $self->js - ->run('kivi.Order.show_email_dialog', $dialog_html) - ->reinit_widgets - ->render($self); + ->run('kivi.Order.show_email_dialog', $dialog_html) + ->reinit_widgets + ->render($self); } # send email -# -# Todo: handling error messages: flash is not displayed in dialog, but in the main form sub action_send_email { my ($self) = @_; - my $errors = $self->save(); - - if (scalar @{ $errors }) { - $self->js->run('kivi.Order.close_email_dialog'); - $self->js->flash('error', $_) foreach @{ $errors }; - return $self->js->render(); + if (!$self->is_final_version) { + eval { + $self->save(); + 1; + } or do { + $self->js->run('kivi.Order.close_email_dialog'); + die $EVAL_ERROR; + }; } - $self->js_reset_order_and_item_ids_after_save; + my @redirect_params = ( + action => 'edit', + type => $self->type, + id => $self->order->id, + ); + # Set the error handler to reload the document and display errors later, + # because the document is already saved and saving can have some side effects + # such as generating a document number, project number or record links, + # which will be up to date when the document is reloaded. + # Hint: Do not use "die" here and try to catch exceptions in subroutine + # calls. You should use "$::form->error" which respects the error handler. + local $::form->{__ERROR_HANDLER} = sub { + flash_later('error', $_[0]); + $self->redirect_to(@redirect_params); + $::dispatcher->end_request; + }; + + # move $::form->{email_form} to $::form my $email_form = delete $::form->{email_form}; if ($email_form->{additional_to}) { @@ -516,7 +568,6 @@ sub action_send_email { } my %field_names = (to => 'email'); - $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form }; # for Form::cleanup which may be called in Form::send_email @@ -531,25 +582,37 @@ sub action_send_email { # Is an old file version available? my $attfile; if ($::form->{attachment_policy} eq 'old_file') { - $attfile = SL::File->get_all(object_id => $self->order->id, - object_type => $self->type, - file_type => 'document', - print_variant => $::form->{formname}); + $attfile = SL::File->get_all( + object_id => $self->order->id, + object_type => $self->type, + print_variant => $::form->{formname}, + ); + } + + if ($self->is_final_version && $::form->{attachment_policy} eq 'old_file' && !$attfile) { + $::form->error(t8('Re-sending a final version was requested, but the latest version of the document could not be found')); } - if ($::form->{attachment_policy} ne 'no_file' && !($::form->{attachment_policy} eq 'old_file' && $attfile)) { + if ( !$self->is_final_version + && $::form->{attachment_policy} ne 'no_file' + && !($::form->{attachment_policy} eq 'old_file' && $attfile) + ) { my $doc; - my @errors = $self->generate_doc(\$doc, {media => $::form->{media}, - format => $::form->{print_options}->{format}, - formname => $::form->{print_options}->{formname}, - language => $self->order->language, - printer_id => $::form->{print_options}->{printer_id}, - groupitems => $::form->{print_options}->{groupitems}}); + my @errors = $self->generate_doc(\$doc, { + media => $::form->{media}, + format => $::form->{print_options}->{format}, + formname => $::form->{print_options}->{formname}, + language => $self->order->language, + printer_id => $::form->{print_options}->{printer_id}, + groupitems => $::form->{print_options}->{groupitems}, + }); if (scalar @errors) { - return $self->js->flash('error', t8('Generating the document failed: #1', $errors[0]))->render($self); + $::form->error(t8('Generating the document failed: #1', $errors[0])); } - my @warnings = $self->store_doc_to_webdav_and_filemanagement($doc, $::form->{attachment_filename}, $::form->{formname}); + my @warnings = $self->store_doc_to_webdav_and_filemanagement( + $doc, $::form->{attachment_filename}, $::form->{formname} + ); if (scalar @warnings) { flash_later('warning', $_) for @warnings; } @@ -559,36 +622,53 @@ sub action_send_email { $sfile->fh->close; $::form->{tmpfile} = $sfile->file_name; - $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email + $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be + # called in Form::send_email } - $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a linked record to the mail + $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a + # linked record to the mail $::form->send_email(\%::myconfig, $::form->{print_options}->{format}); + flash_later('info', t8('The email has been sent.')); + $self->save_history('MAILED'); + # internal notes unless no email journal unless ($::instance_conf->get_email_journal) { my $intnotes = $self->order->intnotes; $intnotes .= "\n\n" if $self->order->intnotes; - $intnotes .= t8('[email]') . "\n"; - $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n"; - $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n"; - $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc}; - $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc}; - $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n"; + $intnotes .= t8('[email]') . "\n"; + $intnotes .= t8('Date') . ": " . $::locale->format_date_object( + DateTime->now_local, + precision => 'seconds') . "\n"; + $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n"; + $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc}; + $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc}; + $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n"; $intnotes .= t8('Message') . ": " . SL::HTML::Util->strip($::form->{message}); $self->order->update_attributes(intnotes => $intnotes); } - $self->save_history('MAILED'); - - flash_later('info', t8('The email has been sent.')); + if ($::instance_conf->get_lock_oe_subversions && !$self->is_final_version) { + my $file_id; + if ($::instance_conf->get_doc_storage && $::form->{attachment_policy} ne 'no_file') { + # self is generated on the fly. form is a file from the dms + # TODO: for the case Filesystem and Webdav we want the real file from the filesystem + # for the nyi case DMS/CMIS we need a gloid or whatever the system offers (elo_id for ELO) + # DMS kivi version should have a record_link to email_journal + # the record link has to refer to the correct version -> helper table file <-> file_version + $file_id = $self->{file_id} || $::form->{file_id}; + $::form->error("No file id") unless $file_id; + } - my @redirect_params = ( - action => 'edit', - type => $self->type, - id => $self->order->id, - ); + # email is sent -> set this version to final and link to journal and file + my $current_version = SL::DB::Manager::OrderVersion->get_all(where => [oe_id => $self->order->id, final_version => 0]); + $::form->error("Invalid version state") unless scalar @{ $current_version } == 1; + $current_version->[0]->update_attributes(file_id => $file_id, + email_journal_id => $::form->{email_journal_id}, + final_version => 1); + } $self->redirect_to(@redirect_params); } @@ -710,7 +790,7 @@ sub action_get_has_active_periodic_invoices { $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id}; my $has_active_periodic_invoices = - $self->type eq sales_order_type() + $self->type eq SALES_ORDER_TYPE() && $config && $config->active && (!$config->end_date || ($config->end_date > DateTime->today_local)) @@ -719,24 +799,31 @@ sub action_get_has_active_periodic_invoices { $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' }); } -# save the order and redirect to the frontend subroutine for a new -# delivery order -sub action_save_and_delivery_order { +sub action_save_and_new_record { my ($self) = @_; - - $self->save_and_redirect_to( - controller => 'oe.pl', - action => 'oe_delivery_order_from_order', - ); -} - -sub action_save_and_supplier_delivery_order { - my ($self) = @_; - - $self->save_and_redirect_to( - controller => 'controller.pl', - action => 'DeliveryOrder/add_from_order', - type => 'supplier_delivery_order', + my $to_type = $::form->{to_type}; + my $to_controller = get_object_name_from_type($to_type); + + $self->save(); + flash_later('info', $self->type_data->text('saved')); + + my %additional_params = (); + if ($::form->{only_selected_item_positions}) { # ids can be unset before save + my $item_positions = $::form->{selected_item_positions} || []; + my @from_item_ids = map { $self->order->items_sorted->[$_]->id } @$item_positions; + $additional_params{from_item_ids} = \@from_item_ids; + } + + $self->redirect_to( + controller => $to_controller, + action => 'add_from_record', + type => $to_type, + from_id => $self->order->id, + from_type => $self->order->type, + email_journal_id => $::form->{workflow_email_journal_id}, + email_attachment_id => $::form->{workflow_email_attachment_id}, + callback => $::form->{workflow_email_callback}, + %additional_params, ); } @@ -748,6 +835,9 @@ sub action_save_and_invoice { $self->save_and_redirect_to( controller => 'oe.pl', action => 'oe_invoice_from_order', + email_journal_id => $::form->{workflow_email_journal_id}, + email_attachment_id => $::form->{workflow_email_attachment_id}, + callback => $::form->{workflow_email_callback}, ); } @@ -758,6 +848,9 @@ sub action_save_and_invoice_for_advance_payment { controller => 'oe.pl', action => 'oe_invoice_from_order', new_invoice_type => 'invoice_for_advance_payment', + email_journal_id => $::form->{workflow_email_journal_id}, + email_attachment_id => $::form->{workflow_email_attachment_id}, + callback => $::form->{workflow_email_callback}, ); } @@ -768,27 +861,25 @@ sub action_save_and_final_invoice { controller => 'oe.pl', action => 'oe_invoice_from_order', new_invoice_type => 'final_invoice', + email_journal_id => $::form->{workflow_email_journal_id}, + email_attachment_id => $::form->{workflow_email_attachment_id}, + callback => $::form->{workflow_email_callback}, ); } -# workflow from sales order to sales quotation -sub action_sales_quotation { - $_[0]->workflow_sales_or_request_for_quotation(); -} - -# workflow from sales order to sales quotation -sub action_request_for_quotation { - $_[0]->workflow_sales_or_request_for_quotation(); -} - -# workflow from sales quotation to sales order -sub action_sales_order { - $_[0]->workflow_sales_or_purchase_order(); -} +# workflows to all types of this controller +sub action_save_and_order_workflow { + my ($self) = @_; -# workflow from rfq to purchase order -sub action_purchase_order { - $_[0]->workflow_sales_or_purchase_order(); + $self->save_and_redirect_to( + action => 'order_workflow', + type => $self->type, + to_type => $::form->{to_type}, + use_shipto => $::form->{use_shipto}, + email_journal_id => $::form->{workflow_email_journal_id}, + email_attachment_id => $::form->{workflow_email_attachment_id}, + callback => $::form->{workflow_email_callback}, + ); } # workflow from purchase order to ap transaction @@ -798,16 +889,64 @@ sub action_save_and_ap_transaction { $self->save_and_redirect_to( controller => 'ap.pl', action => 'add_from_purchase_order', + email_journal_id => $::form->{workflow_email_journal_id}, + email_attachment_id => $::form->{workflow_email_attachment_id}, + callback => $::form->{workflow_email_callback}, ); } +sub action_order_workflow { + my ($self) = @_; + + $self->load_order; + + my $destination_type = $::form->{to_type} ? $::form->{to_type} : ''; + + my $from_side = $self->order->is_sales ? 'sales' : 'purchase'; + my $to_side = (any { $destination_type eq $_ } (SALES_ORDER_INTAKE_TYPE(), SALES_ORDER_TYPE(), SALES_QUOTATION_TYPE())) ? 'sales' : 'purchase'; + + # check for direct delivery + # copy shipto in custom shipto (custom shipto will be copied by new_from() in case) + my $custom_shipto; + if ( $from_side eq 'sales' && $to_side eq 'purchase' + && $::form->{use_shipto} && $self->order->shipto) { + $custom_shipto = $self->order->shipto->clone('SL::DB::Order'); + } + + my $no_linked_records = (any { $destination_type eq $_ } (SALES_QUOTATION_TYPE(), REQUEST_QUOTATION_TYPE())) + && $from_side eq $to_side; + + $self->order(SL::Model::Record->new_from_workflow($self->order, $destination_type, no_linked_records => $no_linked_records)); + + delete $::form->{id}; + + if (!$no_linked_records) { + $self->{converted_from_oe_id} = $self->order->{ RECORD_ID() }; + $_ ->{converted_from_orderitems_id} = $_ ->{ RECORD_ITEM_ID() } for @{ $self->order->items_sorted }; + } + + if ($from_side eq 'sales' && $to_side eq 'purchase') { + if ($::form->{use_shipto}) { + $self->order->custom_shipto($custom_shipto) if $custom_shipto; + } else { + # remove any custom shipto if not wanted + $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])); + } + } + + $self->reinit_after_new_order(); + + $self->action_add; +} + # set form elements in respect to a changed customer or vendor # # This action is called on an change of the customer/vendor picker. sub action_customer_vendor_changed { my ($self) = @_; - setup_order_from_cv($self->order); + $self->order(SL::Model::Record->update_after_customer_vendor_change($self->order)); + $self->recalc(); my $cv_method = $self->cv; @@ -852,45 +991,6 @@ sub action_customer_vendor_changed { $self->js->render(); } -# open the dialog for customer/vendor details -sub action_show_customer_vendor_details_dialog { - my ($self) = @_; - - my $is_customer = 'customer' eq $::form->{vc}; - my $cv; - if ($is_customer) { - $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load; - } else { - $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load; - } - - my %details = map { $_ => $cv->$_ } @{$cv->meta->columns}; - $details{discount_as_percent} = $cv->discount_as_percent; - $details{creditlimt} = $cv->creditlimit_as_number; - $details{business} = $cv->business->description if $cv->business; - $details{language} = $cv->language_obj->description if $cv->language_obj; - $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term; - $details{payment_terms} = $cv->payment->description if $cv->payment; - $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup; - - if ($is_customer) { - foreach my $entry (@{ $cv->additional_billing_addresses }) { - push @{ $details{ADDITIONAL_BILLING_ADDRESSES} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} }; - } - } - foreach my $entry (@{ $cv->shipto }) { - push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} }; - } - foreach my $entry (@{ $cv->contacts }) { - push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} }; - } - - $_[0]->render('common/show_vc_details', { layout => 0 }, - is_customer => $is_customer, - %details); - -} - # called if a unit in an existing item row is changed sub action_unit_changed { my ($self) = @_; @@ -922,13 +1022,16 @@ sub action_update_item_input_row { my $record = $self->order; my $item = SL::DB::OrderItem->new(%$form_attr); + $item->qty(1) if !$item->qty; $item->unit($item->part->unit); - my ($price_src, $discount_src) = get_best_price_and_discount_source($record, $item, 0); + my ($price_src, $discount_src) = SL::Model::Record->get_best_price_and_discount_source($record, $item, ignore_given => 0); + + my $texts = get_part_texts($item->part, $record->language_id); $self->js ->val ('#add_item_unit', $item->unit) - ->val ('#add_item_description', $item->part->description) + ->val ('#add_item_description', $texts->{description}) ->val ('#add_item_sellprice_as_number', '') ->attr ('#add_item_sellprice_as_number', 'placeholder', $price_src->price_as_number) ->attr ('#add_item_sellprice_as_number', 'title', $price_src->source_description) @@ -968,7 +1071,7 @@ sub action_add_item { ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html); } else { $self->js - ->append('#row_table_id', $row_as_html); + ->before('#row_table_footer', $row_as_html); } if ( $item->part->is_assortment ) { @@ -998,7 +1101,7 @@ sub action_add_item { ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html); } else { $self->js - ->append('#row_table_id', $row_as_html); + ->before('#row_table_footer', $row_as_html); } }; }; @@ -1007,12 +1110,19 @@ sub action_add_item { ->val('.add_item_input', '') ->attr('.add_item_input', 'placeholder', '') ->attr('.add_item_input', 'title', '') + ->attr('#add_item_qty_as_number', 'placeholder', '1') ->run('kivi.Order.init_row_handlers') ->run('kivi.Order.renumber_positions') ->focus('#add_item_parts_id_name'); $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id}; + # alternate scroll behaviour if item input below positions and unlimited scroll height + $self->js->run('kivi.Order.scroll_page_after_row_insert', $item_id) + if 0 == SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height + && SL::Helper::UserPreferences::ItemInputPosition->new()->get_order_item_input_position + // $::instance_conf->get_order_item_input_position; + $self->js_redisplay_amounts_and_taxes; $self->js->render(); } @@ -1173,31 +1283,25 @@ sub action_create_part { sub action_return_from_create_part { my ($self) = @_; - $self->{created_part} = SL::DB::Part->new(id => delete $::form->{new_parts_id})->load if $::form->{new_parts_id}; + $self->{created_part} = SL::DB::Part->new( + id => delete $::form->{new_parts_id} + )->load if $::form->{new_parts_id}; $::auth->restore_form_from_session(delete $::form->{previousform}); - # set item ids to new fake id, to identify them as new items - foreach my $item (@{$self->order->items_sorted}) { - $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000); - } - - $self->recalc(); - $self->get_unalterable_data(); - $self->pre_render(); - - # trigger rendering values for second row/longdescription as hidden, - # because they are loaded only on demand. So we need to keep the values - # from the source. - $_->{render_second_row} = 1 for @{ $self->order->items_sorted }; - $_->{render_longdescription} = 1 for @{ $self->order->items_sorted }; - - $self->render( - 'order/form', - title => $self->get_title_for('edit'), - %{$self->{template_args}} - ); + $self->order($self->init_order); + $self->reinit_after_new_order(); + if ($self->order->id) { + $self->pre_render(); + $self->render( + 'order/form', + title => $self->type_data->text('edit'), + %{$self->{template_args}} + ); + } else { + $self->action_add; + } } # load the second row for one or more items @@ -1233,8 +1337,7 @@ sub action_update_row_from_master_data { $item->description($texts->{description}); $item->longdescription($texts->{longdescription}); - my ($price_src, $discount_src) = get_best_price_and_discount_source($self->order, $item, 1); - + my ($price_src, $discount_src) = SL::Model::Record->get_best_price_and_discount_source($self->order, $item, ignore_given => 1); $item->sellprice($price_src->price); $item->active_price_source($price_src); $item->discount($discount_src->discount); @@ -1265,23 +1368,8 @@ sub action_update_row_from_master_data { sub action_save_phone_note { my ($self) = @_; - if (!$::form->{phone_note}->{subject} || !$::form->{phone_note}->{body}) { - return $self->js->flash('error', t8('Phone note needs a subject and a body.'))->render; - } - - my $phone_note; - if ($::form->{phone_note}->{id}) { - $phone_note = first { $_->id == $::form->{phone_note}->{id} } @{$self->order->phone_notes}; - return $self->js->flash('error', t8('Phone note not found for this order.'))->render if !$phone_note; - } - - $phone_note = SL::DB::Note->new() if !$phone_note; - my $is_new = !$phone_note->id; - - $phone_note->assign_attributes(%{ $::form->{phone_note} }, - trans_id => $self->order->id, - trans_module => 'oe', - employee => SL::DB::Manager::Employee->current); + my $phone_note = $self->parse_phone_note; + my $is_new = !$phone_note->id; $phone_note->save; $self->order(SL::DB::Order->new(id => $self->order->id)->load); @@ -1292,6 +1380,7 @@ sub action_save_phone_note { ->replaceWith('#phone-notes', $tab_as_html) ->html('#num_phone_notes', (scalar @{$self->order->phone_notes}) ? ' (' . scalar @{$self->order->phone_notes} . ')' : '') ->flash('info', $is_new ? t8('Phone note has been created.') : t8('Phone note has been updated.')) + ->reinit_widgets ->render; } @@ -1311,9 +1400,86 @@ sub action_delete_phone_note { ->replaceWith('#phone-notes', $tab_as_html) ->html('#num_phone_notes', (scalar @{$self->order->phone_notes}) ? ' (' . scalar @{$self->order->phone_notes} . ')' : '') ->flash('info', t8('Phone note has been deleted.')) + ->reinit_widgets ->render; } +sub action_close_quotations { + my ($self) = @_; + + my @redirect_params = $::form->{callback} ? ($::form->{callback}) + : (controller => 'LoginScreen', action => 'user_login'); + + if (!$::form->{ids} || !@{$::form->{ids}}) { + flash_later('info', t8('Nothing selected!')); + $self->redirect_to(@redirect_params); + $::dispatcher->end_request; + } + + my $sales_quotations = SL::DB::Manager::Order->get_all(where => [id => $::form->{ids}, + or => [closed => 0, closed => undef], + record_type => SALES_QUOTATION_TYPE()]); + + my $request_quotations = SL::DB::Manager::Order->get_all(where => [id => $::form->{ids}, + or => [closed => 0, closed => undef], + record_type => REQUEST_QUOTATION_TYPE()]); + + $::auth->assert('sales_quotation_edit') if scalar @$sales_quotations; + $::auth->assert('request_quotation_edit') if scalar @$request_quotations; + + my $employee_id = SL::DB::Manager::Employee->current->id; + SL::DB->client->with_transaction(sub { + SL::DB::Manager::Order->update_all(set => {closed => 1}, + where => [id => $::form->{ids}]); + + foreach my $quotation (@$sales_quotations, @$request_quotations) { + SL::DB::History->new( + trans_id => $quotation->id, + employee_id => $employee_id, + what_done => $quotation->type, + snumbers => 'quonumber_' . $quotation->number, + addition => 'SAVED', + )->save; + } + + 1; + }) || do { + $::form->error(t8('Closing the selected quotations failed: #1', SL::DB->client->error)); + }; + + flash_later('info', t8('The selected quotations where closed.')); + $self->redirect_to(@redirect_params); +} + +sub action_show_conversion_to_purchase_delivery_order_item_selection { + my ($self) = @_; + + my $items = $self->order->items_sorted; + + if (@$items) { + my @part_ids = uniq map { $_->{parts_id} } @$items; + my %parts_by_id = map { ($_->id => $_) } @{ SL::DB::Manager::Part->get_all(where => [ id => \@part_ids ]) }; + my %make_models_by_id = map { ($_->parts_id => $_->model) } @{ + SL::DB::Manager::MakeModel->get_all( + where => [ + parts_id => \@part_ids, + make => $::form->{order}->{vendor_id}, + ]) + }; + + foreach my $item (@$items) { + $item->{partnumber} = $parts_by_id{ $item->{parts_id} }->partnumber; + $item->{vendor_partnumber} = $make_models_by_id{ $item->{parts_id} }; + } + } + + $self->render( + 'order/tabs/_purchase_delivery_order_item_selection', + { layout => 0 }, + ITEMS => $items, + ); +} + sub js_load_second_row { my ($self, $item, $item_id, $do_parse) = @_; @@ -1410,7 +1576,8 @@ sub js_reset_order_and_item_ids_after_save { $self->js ->val('#id', $self->order->id) - ->val('#converted_from_oe_id', '') + ->val('#converted_from_record_type_ref', '') + ->val('#converted_from_record_id', '') ->val('#order_' . $self->nr_key(), $self->order->number); my $idx = 0; @@ -1424,7 +1591,9 @@ sub js_reset_order_and_item_ids_after_save { } continue { $idx++; } - $self->js->val('[name="converted_from_orderitems_ids[+]"]', ''); + $self->js->val('[name="converted_from_record_item_type_refs[+]"]', ''); + $self->js->val('[name="converted_from_record_item_ids[+]"]', ''); + $self->js->val('[name="basket_item_ids[+]"]', ''); } # @@ -1432,27 +1601,24 @@ sub js_reset_order_and_item_ids_after_save { # sub init_valid_types { - [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ]; + $_[0]->type_data->valid_types; } sub init_type { my ($self) = @_; - if (none { $::form->{type} eq $_ } @{$self->valid_types}) { + my $type = $self->order->record_type; + if (none { $type eq $_ } @{$self->valid_types}) { die "Not a valid type for order"; } - $self->type($::form->{type}); + $self->type($type); } sub init_cv { my ($self) = @_; - my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer' - : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor' - : die "Not a valid type for order"; - - return $cv; + return $self->type_data->properties('customervendor'); } sub init_search_cvpartnumber { @@ -1486,31 +1652,33 @@ sub init_all_price_factors { sub init_part_picker_classification_ids { my ($self) = @_; - my $attribute = 'used_for_' . ($self->type =~ m{sales} ? 'sale' : 'purchase'); - return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => [ $attribute => 1 ]) } ]; + return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all( + where => $self->type_data->part_classification_query()) } ]; +} + +sub init_is_final_version { + # VALID States for current Sales Version + # 1. save create version without email_id -> open + # 2. send email set email_id for version 1 -> final + # 3. save and subversion new version without email_id -> open + # 4. send email set email_id for current subversion -> final + # for all versions > 1 set postfix -2 .. -n for recordnumber + return $::instance_conf->get_lock_oe_subversions ? # conf enabled + $_[0]->order->id ? # is saved + $_[0]->order->is_final_version : # is final + undef : # is not final + undef; # conf disabled } sub check_auth { my ($self) = @_; - - my $right_for = { map { $_ => $_.'_edit' . ' | ' . $_.'_view' } @{$self->valid_types} }; - - my $right = $right_for->{ $self->type }; - $right ||= 'DOES_NOT_EXIST'; - - $::auth->assert($right); + $::auth->assert($self->type_data->rights('view')); } sub check_auth_for_edit { my ($self) = @_; - - my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} }; - - my $right = $right_for->{ $self->type }; - $right ||= 'DOES_NOT_EXIST'; - - $::auth->assert($right); + $::auth->assert($self->type_data->rights('edit')); } # build the selection box for contacts @@ -1593,7 +1761,13 @@ sub build_tax_rows { my $rows_as_html; foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) { - $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded); + $rows_as_html .= $self->p->render( + 'order/tabs/_tax_row', + SELF => $self, + TAX => $tax, + TAXINCLUDED => $self->order->taxincluded, + QUOTATION => $self->order->quotation + ); } return $rows_as_html; } @@ -1627,9 +1801,7 @@ sub load_order { $self->order(SL::DB::Order->new(id => $::form->{id})->load); - # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs. - # You need a custom shipto object to call cvars_by_config to get the cvars. - $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto; + $self->reinit_after_new_order(); return $self->order; } @@ -1647,27 +1819,53 @@ sub make_order { # be retrieved via items until the order is saved. Adding empty items to new # order here solves this problem. my $order; - $order = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id}; - $order ||= SL::DB::Order->new(orderitems => [], - quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())), - currency_id => $::instance_conf->get_currency_id(),); + if ($::form->{id}) { + $order = SL::DB::Order->new( + id => $::form->{id} + )->load( + with => [ + 'orderitems', + 'orderitems.part', + ] + ); + } else { + $order = SL::DB::Order->new( + orderitems => [], + record_type => $::form->{type}, + currency_id => $::instance_conf->get_currency_id(), + ); + $order = SL::Model::Record->update_after_new($order) + } - my $cv_id_method = $self->cv . '_id'; + my $cv_id_method = $order->type_data->properties('customervendor'). '_id'; if (!$::form->{id} && $::form->{$cv_id_method}) { $order->$cv_id_method($::form->{$cv_id_method}); - setup_order_from_cv($order); + $order = SL::Model::Record->update_after_customer_vendor_change($order); } - my $form_orderitems = delete $::form->{order}->{orderitems}; - my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config}; + # don't assign hashes as objects + my $form_orderitems = delete $::form->{order}->{orderitems}; + my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config}; $order->assign_attributes(%{$::form->{order}}); + # restore form values + $::form->{order}->{orderitems} = $form_orderitems; + $::form->{order}->{periodic_invoices_config} = $form_periodic_invoices_config; + $self->setup_custom_shipto_from_form($order, $::form); - if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) { - my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new); - $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs); + if ( + my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? + SL::YAML::Load($form_periodic_invoices_config) + : undef + ) { + my $periodic_invoices_config = + $order->periodic_invoices_config + || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new); + $periodic_invoices_config->assign_attributes( + %$periodic_invoices_config_attrs + ); } # remove deleted items @@ -1741,17 +1939,19 @@ sub new_item { $item->qty(1.0) if !$item->qty; $item->unit($item->part->unit) if !$item->unit; - my ($price_src, $discount_src) = get_best_price_and_discount_source($record, $item, 0); + my ($price_src, $discount_src) = SL::Model::Record->get_best_price_and_discount_source($record, $item, ignore_given => 0); + + my $texts = get_part_texts($item->part, $record->language_id); my %new_attr; - $new_attr{description} = $item->part->description if ! $item->description; + $new_attr{description} = $texts->{description} if ! $item->description; $new_attr{qty} = 1.0 if ! $item->qty; $new_attr{price_factor_id} = $item->part->price_factor_id if ! $item->price_factor_id; $new_attr{sellprice} = $price_src->price; $new_attr{discount} = $discount_src->discount; $new_attr{active_price_source} = $price_src; $new_attr{active_discount_source} = $discount_src; - $new_attr{longdescription} = $item->part->notes if ! defined $attr->{longdescription}; + $new_attr{longdescription} = $texts->{longdescription} if ! defined $attr->{longdescription}; $new_attr{project_id} = $record->globalproject_id; $new_attr{lastcost} = $record->is_sales ? $item->part->lastcost : 0; @@ -1760,29 +1960,22 @@ sub new_item { # saved. Adding empty custom_variables to new orderitem here solves this problem. $new_attr{custom_variables} = []; - my $texts = get_part_texts($item->part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription}); - - $item->assign_attributes(%new_attr, %{ $texts }); + $item->assign_attributes(%new_attr); return $item; } -sub setup_order_from_cv { - my ($order) = @_; - - $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id language_id)); - - $order->intnotes($order->customervendor->notes); - - return if !$order->is_sales; - - $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id); - $order->taxincluded(defined($order->customer->taxincluded_checked) - ? $order->customer->taxincluded_checked - : $::myconfig{taxincluded_checked}); +sub get_basket_info_from_from { + my ($self) = @_; - my $address = $order->customer->default_billing_address;; - $order->billing_address_id($address ? $address->id : undef); + my $order = $self->order; + my $basket_item_ids = $::form->{basket_item_ids}; + if (scalar @{ $basket_item_ids || [] }) { + for my $idx (0 .. $#{ $order->items_sorted }) { + my $order_item = $order->items_sorted->[$idx]; + $order_item->{basket_item_id} = $basket_item_ids->[$idx]; + } + } } # setup custom shipto from form @@ -1799,7 +1992,11 @@ sub setup_custom_shipto_from_form { if ($order->shipto) { $self->is_custom_shipto_to_delete(1); } else { - my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])); + my $custom_shipto = + $order->custom_shipto + || $order->custom_shipto( + SL::DB::Shipto->new(module => 'OE', custom_variables => []) + ); my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form}; my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form}; @@ -1844,28 +2041,48 @@ sub get_unalterable_data { } } -# delete the order +# parse new or updated phone note # -# And remove related files in the spool directory -sub delete { +# And put them into the order object. +sub parse_phone_note { my ($self) = @_; - my $errors = []; - my $db = $self->order->db; + if (!$::form->{phone_note}->{subject} || !$::form->{phone_note}->{body}) { + die t8('Phone note needs a subject and a body.'); + } - $db->with_transaction( - sub { - my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) }; - $self->order->delete; - my $spool = $::lx_office_conf{paths}->{spool}; - unlink map { "$spool/$_" } @spoolfiles if $spool; + my $phone_note; + if ($::form->{phone_note}->{id}) { + $phone_note = first { $_->id == $::form->{phone_note}->{id} } @{$self->order->phone_notes}; + die t8('Phone note not found for this order.') if !$phone_note; + } - $self->save_history('DELETED'); + $phone_note = SL::DB::Note->new() if !$phone_note; + my $is_new = !$phone_note->id; - 1; - }) || push(@{$errors}, $db->error); + $phone_note->assign_attributes(%{ $::form->{phone_note} }, + trans_id => $self->order->id, + trans_module => 'oe', + employee => SL::DB::Manager::Employee->current); - return $errors; + $self->order->add_phone_notes($phone_note) if $is_new; + return $phone_note; +} + +sub check_if_periodic_invoices_contact_matches_customer { + my ($self) = @_; + + return if !$self->order->is_type(SL::DB::Order::SALES_ORDER_TYPE()); + + my $cfg = SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $self->order->id); + return if !$cfg || !$cfg->email_recipient_contact_id; + + my $contact = SL::DB::Manager::Contact->find_by(cp_id => $cfg->email_recipient_contact_id); + return if !$contact; + + if ($contact->cp_cv_id != $self->order->customer_id) { + $cfg->update_attributes(email_recipient_contact_id => undef); + } } # save the order @@ -1874,189 +2091,136 @@ sub delete { sub save { my ($self) = @_; - my $errors = []; - my $db = $self->order->db; + my $is_new = !$self->order->id; - # check for new or updated phone note - if ($::form->{phone_note}->{subject} || $::form->{phone_note}->{body}) { - if (!$::form->{phone_note}->{subject} || !$::form->{phone_note}->{body}) { - return [t8('Phone note needs a subject and a body.')]; - } - - my $phone_note; - if ($::form->{phone_note}->{id}) { - $phone_note = first { $_->id == $::form->{phone_note}->{id} } @{$self->order->phone_notes}; - return [t8('Phone note not found for this order.')] if !$phone_note; - } + $self->parse_phone_note if $::form->{phone_note}->{subject} || $::form->{phone_note}->{body}; - $phone_note = SL::DB::Note->new() if !$phone_note; - my $is_new = !$phone_note->id; + # Test for order locked items if they are not wanted for this record type. + if ($self->type_data->no_order_locked_parts) { + my @order_locked_positions = map { $_->position } grep { $_->part->order_locked } @{ $self->order->items_sorted }; + die t8('This record contains not orderable items at position #1', join ', ', @order_locked_positions) if @order_locked_positions; + } - $phone_note->assign_attributes(%{ $::form->{phone_note} }, - trans_id => $self->order->id, - trans_module => 'oe', - employee => SL::DB::Manager::Employee->current); + # create first version if none exists + $self->order->add_order_version(SL::DB::OrderVersion->new(version => 1)) if !$self->order->order_version; - $self->order->add_phone_notes($phone_note) if $is_new; - } + set_record_link_conversions($self->order, + delete $::form->{RECORD_TYPE_REF()} + => delete $::form->{RECORD_ID()}, + delete $::form->{RECORD_ITEM_TYPE_REF()} + => delete $::form->{RECORD_ITEM_ID()}, + ); - $db->with_transaction(sub { - # delete custom shipto if it is to be deleted or if it is empty - if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) { - $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id; - $self->order->custom_shipto(undef); + my @converted_from_oe_ids; + if ($self->order->{RECORD_TYPE_REF()} eq 'SL::DB::Order' + && $self->order->{RECORD_ID()}) { + @converted_from_oe_ids = split ' ', $self->order->{RECORD_ID()}; + } + + # check for purchase basket items + my %basket_item_id_to_orderitem = + map { $_->{basket_item_id} => $_ } + grep { $_->{basket_item_id} ne '' } + $self->order->orderitems; + my @basket_item_ids = keys %basket_item_id_to_orderitem; + if (scalar @basket_item_ids) { + my $basket_items = SL::DB::Manager::PurchaseBasketItem->get_all( + where => [ id => \@basket_item_ids ]); + if (scalar @$basket_items != scalar @basket_item_ids) { + my %basket_item_exists = map { $_->id => 1 } @$basket_items; + my @missing_for_positions = + map { $_->position } + map { $basket_item_id_to_orderitem{$_} } + grep { !$basket_item_exists{$_} } + @basket_item_ids; + return [t8('Purchase basket item not existing any more for position(s): #1.', + join(',', @missing_for_positions))]; } + } - SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []}; - $self->order->save(cascade => 1); - - # link records - if ($::form->{converted_from_oe_id}) { - my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id}; + my $objects_to_close = scalar @converted_from_oe_ids + ? SL::DB::Manager::Order->get_all(where => [ + id => \@converted_from_oe_ids, + or => [ record_type => SALES_QUOTATION_TYPE(), + record_type => REQUEST_QUOTATION_TYPE(), + (record_type => PURCHASE_QUOTATION_INTAKE_TYPE()) x $self->order->is_type(PURCHASE_ORDER_TYPE()), + (record_type => PURCHASE_ORDER_TYPE()) x $self->order->is_type(PURCHASE_ORDER_CONFIRMATION_TYPE()) ] + ]) + : undef; + + my $items_to_delete = scalar @{ $self->item_ids_to_delete || [] } + ? SL::DB::Manager::OrderItem->get_all(where => [id => $self->item_ids_to_delete]) + : undef; + + SL::Model::Record->save($self->order, + with_validity_token => { scope => SL::DB::ValidityToken::SCOPE_ORDER_SAVE(), token => $::form->{form_validity_token} }, + delete_custom_shipto => $self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty), + items_to_delete => $items_to_delete, + objects_to_close => $objects_to_close, + link_requirement_specs_linking_to_created_from_objects => \@converted_from_oe_ids, + set_project_in_linked_requirement_specs => 1, + ); - foreach my $converted_from_oe_id (@converted_from_oe_ids) { - my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load; - $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/; - $src->link_to_record($self->order); - } - if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) { - my $idx = 0; - foreach (@{ $self->order->items_sorted }) { - my $from_id = $::form->{converted_from_orderitems_ids}->[$idx]; - next if !$from_id; - SL::DB::RecordLink->new(from_table => 'orderitems', - from_id => $from_id, - to_table => 'orderitems', - to_id => $_->id - )->save; - $idx++; - } - } + if ($::form->{email_journal_id}) { + my $email_journal = SL::DB::EmailJournal->new( + id => delete $::form->{email_journal_id} + )->load; + $email_journal->link_to_record_with_attachment( + $self->order, + delete $::form->{email_attachment_id} + ); + } - $self->link_requirement_specs_linking_to_created_from_objects(@converted_from_oe_ids); + if ($is_new && $self->order->is_sales && $::lx_office_conf{imap_client}->{enabled}) { + my $imap_client = SL::IMAPClient->new(%{$::lx_office_conf{imap_client}}); + if ($imap_client) { + $imap_client->create_folder_for_record(record => $self->order); } + } - $self->set_project_in_linked_requirement_specs if $self->order->globalproject_id; - - $self->save_history('SAVED'); - - 1; - }) || push(@{$errors}, $db->error); + $self->check_if_periodic_invoices_contact_matches_customer; - return $errors; + delete $::form->{form_validity_token}; } -sub workflow_sales_or_request_for_quotation { +sub reinit_after_new_order { my ($self) = @_; - # always save - my $errors = $self->save(); - - if (scalar @{ $errors }) { - $self->js->flash('error', $_) for @{ $errors }; - return $self->js->render(); - } - - my $destination_type = $::form->{type} eq sales_order_type() ? sales_quotation_type() : request_quotation_type(); - - $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type)); - delete $::form->{id}; - - # no linked records from order to quotations - delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids); - - # set item ids to new fake id, to identify them as new items - foreach my $item (@{$self->order->items_sorted}) { - $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000); - } - # change form type - $::form->{type} = $destination_type; + $::form->{type} = $self->order->type; $self->type($self->init_type); - $self->cv ($self->init_cv); + $self->type_data($self->init_type_data); + $self->cv($self->init_cv); $self->check_auth; - $self->recalc(); - $self->get_unalterable_data(); - $self->pre_render(); - - # trigger rendering values for second row as hidden, because they - # are loaded only on demand. So we need to keep the values from the - # source. - $_->{render_second_row} = 1 for @{ $self->order->items_sorted }; - - $self->render( - 'order/form', - title => $self->get_title_for('edit'), - %{$self->{template_args}} - ); -} - -sub workflow_sales_or_purchase_order { - my ($self) = @_; - - # always save - my $errors = $self->save(); + $self->setup_custom_shipto_from_form($self->order, $::form); - if (scalar @{ $errors }) { - $self->js->flash('error', $_) foreach @{ $errors }; - return $self->js->render(); - } - - my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type() - : $::form->{type} eq request_quotation_type() ? purchase_order_type() - : $::form->{type} eq purchase_order_type() ? sales_order_type() - : $::form->{type} eq sales_order_type() ? purchase_order_type() - : ''; - - # check for direct delivery - # copy shipto in custom shipto (custom shipto will be copied by new_from() in case) - my $custom_shipto; - if ( $::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type() - && $::form->{use_shipto} && $self->order->shipto) { - $custom_shipto = $self->order->shipto->clone('SL::DB::Order'); - } - - $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type)); - $self->{converted_from_oe_id} = delete $::form->{id}; - - # set item ids to new fake id, to identify them as new items foreach my $item (@{$self->order->items_sorted}) { + # set item ids to new fake id, to identify them as new items $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000); - } - if ($::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()) { - if ($::form->{use_shipto}) { - $self->order->custom_shipto($custom_shipto) if $custom_shipto; - } else { - # remove any custom shipto if not wanted - $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])); - } + # trigger rendering values for second row as hidden, because they + # are loaded only on demand. So we need to keep the values from the + # source. + $item->{render_second_row} = 1; } - # change form type - $::form->{type} = $destination_type; - $self->type($self->init_type); - $self->cv ($self->init_cv); - $self->check_auth; + # Warn on order locked items if they are not wanted for this record type + if ($self->type_data->no_order_locked_parts) { + my @order_locked_positions = + map { $_->position } + grep { $_->part->order_locked } + @{ $self->order->items_sorted }; + flash('warning', t8( + 'This record contains not orderable items at position #1', + join ', ', @order_locked_positions) + ) if @order_locked_positions; + } - $self->recalc(); $self->get_unalterable_data(); - $self->pre_render(); - - # trigger rendering values for second row as hidden, because they - # are loaded only on demand. So we need to keep the values from the - # source. - $_->{render_second_row} = 1 for @{ $self->order->items_sorted }; - - $self->render( - 'order/form', - title => $self->get_title_for('edit'), - %{$self->{template_args}} - ); + $self->recalc(); } - sub pre_render { my ($self) = @_; @@ -2072,7 +2236,9 @@ sub pre_render { sort_by => 'name'); $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id, obsolete => 0 ] ]); - $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted(); + $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_valid($self->order->delivery_term_id); + $self->{all_statuses} = SL::DB::Manager::OrderStatus->get_all_sorted(where => [ or => [ id => $self->order->order_status_id, + obsolete => 0, ] ] ); $self->{current_employee_id} = SL::DB::Manager::Employee->current->id; $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config); $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ]; @@ -2097,7 +2263,7 @@ sub pre_render { $item->active_discount_source($price_source->discount_from_source($item->active_discount_source)); } - if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) { + if (any { $self->type eq $_ } (SALES_ORDER_INTAKE_TYPE(), SALES_ORDER_TYPE(), PURCHASE_ORDER_TYPE(), PURCHASE_ORDER_CONFIRMATION_TYPE())) { # Calculate shipped qtys here to prevent calling calculate for every item via the items method. # Do not use write_to_objects to prevent order->delivered to be set, because this should be # the value from db, which can be set manually or is set when linked delivery orders are saved. @@ -2116,17 +2282,19 @@ sub pre_render { } } @all_objects; } - if ( (any { $self->type eq $_ } (sales_quotation_type(), sales_order_type())) + if ( (any { $self->type eq $_ } (SALES_QUOTATION_TYPE(), SALES_ORDER_INTAKE_TYPE(), SALES_ORDER_TYPE())) && $::instance_conf->get_transport_cost_reminder_article_number_id ) { $self->{template_args}->{transport_cost_reminder_article} = SL::DB::Part->new(id => $::instance_conf->get_transport_cost_reminder_article_number_id)->load; } $self->{template_args}->{longdescription_dialog_size_percentage} = SL::Helper::UserPreferences::DisplayPreferences->new()->get_longdescription_dialog_size_percentage(); + $self->{template_args}->{order_item_input_position} = SL::Helper::UserPreferences::ItemInputPosition->new()->get_order_item_input_position + // $::instance_conf->get_order_item_input_position; $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted}; $self->{template_args}->{num_phone_notes} = scalar @{ $self->order->phone_notes || [] }; - $::request->{layout}->use_javascript("${_}.js") for qw(kivi.Validator kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery + $::request->{layout}->use_javascript("${_}.js") for qw(kivi.Validator kivi.SalesPurchase kivi.Order kivi.File edit_periodic_invoices_config calculate_qty follow_up show_history); $self->setup_edit_action_bar; } @@ -2134,57 +2302,76 @@ sub pre_render { sub setup_edit_action_bar { my ($self, %params) = @_; - my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())) - || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete) - || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete); - + my @valid = qw( + kivi.Order.check_cv + ); + push @valid, "kivi.Order.check_duplicate_parts" if $::instance_conf->get_order_warn_duplicate_parts; + push @valid, "kivi.Order.check_valid_reqdate" if $::instance_conf->get_order_warn_no_deliverydate; my @req_trans_cost_art = qw(kivi.Order.check_transport_cost_article_presence) x!!$::instance_conf->get_transport_cost_reminder_article_number_id; - my @req_cusordnumber = qw(kivi.Order.check_cusordnumber_presence) x($self->type eq sales_order_type() && $::instance_conf->get_order_warn_no_cusordnumber); + my @req_cusordnumber = qw(kivi.Order.check_cusordnumber_presence) x(( any {$self->type eq $_} (SALES_ORDER_INTAKE_TYPE(), SALES_ORDER_TYPE()) ) && $::instance_conf->get_order_warn_no_cusordnumber); my $has_invoice_for_advance_payment; - if ($self->order->id && $self->type eq sales_order_type()) { + if ($self->order->id && $self->type eq SALES_ORDER_TYPE()) { my $lr = $self->order->linked_records(direction => 'to', to => ['Invoice']); $has_invoice_for_advance_payment = any {'SL::DB::Invoice' eq ref $_ && "invoice_for_advance_payment" eq $_->type} @$lr; } my $has_final_invoice; - if ($self->order->id && $self->type eq sales_order_type()) { + if ($self->order->id && $self->type eq SALES_ORDER_TYPE()) { my $lr = $self->order->linked_records(direction => 'to', to => ['Invoice']); $has_final_invoice = any {'SL::DB::Invoice' eq ref $_ && "final_invoice" eq $_->type} @$lr; } - my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} }; - my $right = $right_for->{ $self->type }; - $right ||= 'DOES_NOT_EXIST'; - my $may_edit_create = $::auth->assert($right, 'may fail'); + my $may_edit_create = $::auth->assert($self->type_data->rights('edit'), 'may fail'); + + my $is_final_version = $self->is_final_version; for my $bar ($::request->layout->get('actionbar')) { $bar->add( combobox => [ action => [ t8('Save'), - call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts, - $::instance_conf->get_order_warn_no_deliverydate, - ], + call => [ 'kivi.Order.save', { + action => 'save', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_order_warn_no_deliverydate, + }], checks => [ 'kivi.Order.check_save_active_periodic_invoices', ['kivi.validate_form','#order_form'], @req_trans_cost_art, @req_cusordnumber, ], - disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, + disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') + : $is_final_version ? t8('This record is the final version. Please create a new sub-version') : undef, ], action => [ t8('Save and Close'), - call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts, - $::instance_conf->get_order_warn_no_deliverydate, - 1 - ], + call => [ 'kivi.Order.save', { + action => 'save', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_order_warn_no_deliverydate, + form_params => [ + { name => 'back_to_caller', value => 1 }, + ], + }], checks => [ 'kivi.Order.check_save_active_periodic_invoices', ['kivi.validate_form','#order_form'], @req_trans_cost_art, @req_cusordnumber, ], - disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, + disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') + : $is_final_version ? t8('This record is the final version. Please create a new sub-version') : undef, + ], + action => [ + t8('Create Sub-Version'), + call => [ 'kivi.Order.save', { action => 'add_subversion' } ], + only_if => $::instance_conf->get_lock_oe_subversions, + disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') + : !$is_final_version ? t8('This sub-version is not yet finalized') + : undef, ], action => [ t8('Save as new'), - call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ], + call => [ 'kivi.Order.save', { + action => 'save_as_new', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + }], checks => [ 'kivi.Order.check_save_active_periodic_invoices', @req_trans_cost_art, @req_cusordnumber, ], @@ -2200,87 +2387,177 @@ sub setup_edit_action_bar { ], action => [ t8('Save and Quotation'), - submit => [ '#order_form', { action => "Order/sales_quotation" } ], - checks => [ @req_trans_cost_art, @req_cusordnumber ], - only_if => (any { $self->type eq $_ } (sales_order_type())), + call => [ 'kivi.submit_ajax_form', $self->url_for(action => "save_and_order_workflow", to_type => SALES_QUOTATION_TYPE()), '#order_form' ], + checks => [ @valid, @req_trans_cost_art, @req_cusordnumber ], + only_if => $self->type_data->show_menu('save_and_quotation'), disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, ], action => [ t8('Save and RFQ'), - submit => [ '#order_form', { action => "Order/request_for_quotation" } ], - only_if => (any { $self->type eq $_ } (purchase_order_type())), + call => [ 'kivi.Order.purchase_check_for_direct_delivery', { to_type => REQUEST_QUOTATION_TYPE() } ], + checks => [ @valid ], + only_if => $self->type_data->show_menu('save_and_rfq'), + disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, + ], + action => [ + t8('Save and Purchase Quotation Intake'), + call => [ 'kivi.submit_ajax_form', $self->url_for(action => "save_and_order_workflow", to_type => PURCHASE_QUOTATION_INTAKE_TYPE()), '#order_form' ], + only_if => $self->type_data->show_menu('save_and_purchase_quotation_intake'), disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, ], action => [ - t8('Save and Sales Order'), - submit => [ '#order_form', { action => "Order/sales_order" } ], - checks => [ @req_trans_cost_art ], - only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())), + t8('Save and Sales Order Intake'), + call => [ 'kivi.submit_ajax_form', $self->url_for(action => "save_and_order_workflow", to_type => SALES_ORDER_INTAKE_TYPE()), '#order_form' ], + only_if => $self->type_data->show_menu('save_and_sales_order_intake'), + disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, + ], + action => [ + t8('Save and Sales Order Confirmation'), + call => [ 'kivi.submit_ajax_form', $self->url_for(action => "save_and_order_workflow", to_type => SALES_ORDER_TYPE()), '#order_form' ], + checks => [ @valid, @req_trans_cost_art ], + only_if => $self->type_data->show_menu('save_and_sales_order'), disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, ], action => [ t8('Save and Purchase Order'), - call => [ 'kivi.Order.purchase_order_check_for_direct_delivery' ], - checks => [ @req_trans_cost_art, @req_cusordnumber ], - only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())), + call => [ 'kivi.Order.purchase_check_for_direct_delivery', { to_type => PURCHASE_ORDER_TYPE() } ], + checks => [ @valid, @req_trans_cost_art, @req_cusordnumber ], + only_if => $self->type_data->show_menu('save_and_purchase_order'), + disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, + ], + action => [ + t8('Save and Purchase Order Confirmation'), + call => [ 'kivi.Order.purchase_check_for_direct_delivery', { to_type => PURCHASE_ORDER_CONFIRMATION_TYPE() } ], + checks => [ @valid, @req_trans_cost_art, @req_cusordnumber ], + only_if => $self->type_data->show_menu('save_and_purchase_order_confirmation'), disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, ], action => [ - t8('Save and Delivery Order'), - call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts, - $::instance_conf->get_order_warn_no_deliverydate, - ], + t8('Save and Sales Delivery Order'), + call => [ 'kivi.Order.save', { + action => 'save_and_new_record', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_order_warn_no_deliverydate, + form_params => [ + { name => 'to_type', value => SALES_DELIVERY_ORDER_TYPE() }, + ], + }], checks => [ 'kivi.Order.check_save_active_periodic_invoices', @req_trans_cost_art, @req_cusordnumber, ], - only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())), + only_if => $self->type_data->show_menu('save_and_sales_delivery_order'), + disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, + ], + action => [ + t8('Save and Purchase Delivery Order'), + call => [ 'kivi.Order.save', { + action => 'save_and_new_record', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_order_warn_no_deliverydate, + form_params => [ + { name => 'to_type', value => PURCHASE_DELIVERY_ORDER_TYPE() }, + ], + }], + checks => [ 'kivi.Order.check_save_active_periodic_invoices', + @req_trans_cost_art, @req_cusordnumber, + ], + only_if => $self->type_data->show_menu('save_and_purchase_delivery_order'), + disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, + ], + action => [ + t8('Save and Purchase Delivery Order with item selection'), + call => [ + 'kivi.Order.show_purchase_delivery_order_select_items', { + action => 'save_and_new_record', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_order_warn_no_deliverydate, + form_params => [ + { name => 'to_type', value => PURCHASE_DELIVERY_ORDER_TYPE() }, + ], + }], + checks => [ @req_trans_cost_art, @req_cusordnumber ], + only_if => $self->type_data->show_menu('save_and_purchase_delivery_order'), disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, ], action => [ t8('Save and Supplier Delivery Order'), - call => [ 'kivi.Order.save', 'save_and_supplier_delivery_order', $::instance_conf->get_order_warn_duplicate_parts, - $::instance_conf->get_order_warn_no_deliverydate, - ], + call => [ 'kivi.Order.save', { + action => 'save_and_new_record', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_order_warn_no_deliverydate, + form_params => [ + { name => 'to_type', value => SUPPLIER_DELIVERY_ORDER_TYPE() }, + ], + }], checks => [ 'kivi.Order.check_save_active_periodic_invoices', @req_trans_cost_art, @req_cusordnumber, ], - only_if => (any { $self->type eq $_ } (purchase_order_type())), + only_if => $self->type_data->show_menu('save_and_supplier_delivery_order'), + disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, + ], + action => [ + t8('Save and Reclamation'), + call => [ 'kivi.Order.save', { + action => 'save_and_new_record', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_order_warn_no_deliverydate, + form_params => [ + { name => 'to_type', + value => $self->order->is_sales ? SALES_RECLAMATION_TYPE() + : PURCHASE_RECLAMATION_TYPE() }, + ], + }], + only_if => $self->type_data->show_menu('save_and_reclamation'), disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, ], action => [ t8('Save and Invoice'), - call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ], + call => [ 'kivi.Order.save', { + action => 'save_and_invoice', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_order_warn_no_deliverydate, + }], checks => [ 'kivi.Order.check_save_active_periodic_invoices', @req_trans_cost_art, @req_cusordnumber, ], disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, + only_if => $self->type_data->show_menu('save_and_invoice'), ], action => [ ($has_invoice_for_advance_payment ? t8('Save and Further Invoice for Advance Payment') : t8('Save and Invoice for Advance Payment')), - call => [ 'kivi.Order.save', 'save_and_invoice_for_advance_payment', $::instance_conf->get_order_warn_duplicate_parts ], + call => [ 'kivi.Order.save', { + action => 'save_and_invoice_for_advance_payment', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + }], checks => [ 'kivi.Order.check_save_active_periodic_invoices', @req_trans_cost_art, @req_cusordnumber, ], disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : $has_final_invoice ? t8('This order has already a final invoice.') : undef, - only_if => (any { $self->type eq $_ } (sales_order_type())), + only_if => $self->type_data->show_menu('save_and_invoice_for_advance_payment'), ], action => [ t8('Save and Final Invoice'), - call => [ 'kivi.Order.save', 'save_and_final_invoice', $::instance_conf->get_order_warn_duplicate_parts ], + call => [ 'kivi.Order.save', { + action => 'save_and_final_invoice', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + }], checks => [ 'kivi.Order.check_save_active_periodic_invoices', @req_trans_cost_art, @req_cusordnumber, ], disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : $has_final_invoice ? t8('This order has already a final invoice.') : undef, - only_if => (any { $self->type eq $_ } (sales_order_type())) && $has_invoice_for_advance_payment, + only_if => $self->type_data->show_menu('save_and_final_invoice') && $has_invoice_for_advance_payment, ], action => [ t8('Save and AP Transaction'), - call => [ 'kivi.Order.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ], - only_if => (any { $self->type eq $_ } (purchase_order_type())), + call => [ 'kivi.Order.save', { + action => 'save_and_ap_transaction', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + }], + only_if => $self->type_data->show_menu('save_and_ap_transaction'), disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, ], @@ -2292,29 +2569,38 @@ sub setup_edit_action_bar { ], action => [ t8('Save and preview PDF'), - call => [ 'kivi.Order.save', 'preview_pdf', $::instance_conf->get_order_warn_duplicate_parts, - $::instance_conf->get_order_warn_no_deliverydate, - ], + call => [ 'kivi.Order.save', { + action => 'preview_pdf', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_order_warn_no_deliverydate, + }], checks => [ @req_trans_cost_art, @req_cusordnumber ], - disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, + disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') + : $is_final_version ? t8('This record is the final version. Please create a new sub-version') : undef, + only_if => $self->type_data->show_menu('save_and_print'), ], action => [ t8('Save and print'), - call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts, - $::instance_conf->get_order_warn_no_deliverydate, - ], + call => [ 'kivi.Order.show_print_options', { warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_order_warn_no_deliverydate }, + ], checks => [ @req_trans_cost_art, @req_cusordnumber ], - disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef, + disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') + : $is_final_version ? t8('This record is the final version. Please create a new sub-version') : undef, + only_if => $self->type_data->show_menu('save_and_print'), ], action => [ - t8('Save and E-mail'), + ($is_final_version ? t8('E-mail') : t8('Save and E-mail')), id => 'save_and_email_action', - call => [ 'kivi.Order.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts, - $::instance_conf->get_order_warn_no_deliverydate, - ], - disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') - : !$self->order->id ? t8('This object has not been saved yet.') - : undef, + call => [ 'kivi.Order.save', { + action => 'save_and_show_email_dialog', + warn_on_duplicates => $::instance_conf->get_order_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_order_warn_no_deliverydate, + }], + disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') + : !$self->order->id ? t8('This object has not been saved yet.') + : undef, + only_if => $self->type_data->show_menu('save_and_email'), ], action => [ t8('Download attachments of all parts'), @@ -2331,7 +2617,7 @@ sub setup_edit_action_bar { disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : !$self->order->id ? t8('This object has not been saved yet.') : undef, - only_if => $deletion_allowed, + only_if => $self->type_data->show_menu('delete'), ], combobox => [ @@ -2466,7 +2752,7 @@ sub make_periodic_invoices_config_from_yaml { sub get_periodic_invoices_status { my ($self, $config) = @_; - return if $self->type ne sales_order_type(); + return if $self->type ne SALES_ORDER_TYPE(); return t8('not configured') if !$config; my $active = ('HASH' eq ref $config) ? $config->{active} @@ -2476,29 +2762,6 @@ sub get_periodic_invoices_status { return $active ? t8('active') : t8('inactive'); } -sub get_title_for { - my ($self, $action) = @_; - - return '' if none { lc($action)} qw(add edit); - - # for locales: - # $::locale->text("Add Sales Order"); - # $::locale->text("Add Purchase Order"); - # $::locale->text("Add Quotation"); - # $::locale->text("Add Request for Quotation"); - # $::locale->text("Edit Sales Order"); - # $::locale->text("Edit Purchase Order"); - # $::locale->text("Edit Quotation"); - # $::locale->text("Edit Request for Quotation"); - - $action = ucfirst(lc($action)); - return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order") - : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order") - : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation") - : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation") - : ''; -} - sub get_item_cvpartnumber { my ($self, $item) = @_; @@ -2538,81 +2801,18 @@ sub get_part_texts { return $texts; } -sub get_best_price_and_discount_source { - my ($record, $item, $ignore_given) = @_; - - my $price_source = SL::PriceSource->new(record_item => $item, record => $record); - - my $price_src; - if ( $item->part->is_assortment ) { - # add assortment items with price 0, as the components carry the price - $price_src = $price_source->price_from_source(""); - $price_src->price(0); - } elsif (!$ignore_given && defined $item->sellprice) { - $price_src = $price_source->price_from_source(""); - $price_src->price($item->sellprice); - } else { - $price_src = $price_source->best_price - ? $price_source->best_price - : $price_source->price_from_source(""); - $price_src->price($::form->round_amount($price_src->price / $record->exchangerate, 5)) if $record->exchangerate; - $price_src->price(0) if !$price_source->best_price; - } - - my $discount_src; - if (!$ignore_given && defined $item->discount) { - $discount_src = $price_source->discount_from_source(""); - $discount_src->discount($item->discount); - } else { - $discount_src = $price_source->best_discount - ? $price_source->best_discount - : $price_source->discount_from_source(""); - $discount_src->discount(0) if !$price_source->best_discount; - } - - return ($price_src, $discount_src); -} - -sub sales_order_type { - 'sales_order'; -} - -sub purchase_order_type { - 'purchase_order'; -} - -sub sales_quotation_type { - 'sales_quotation'; -} - -sub request_quotation_type { - 'request_quotation'; -} - sub nr_key { - return $_[0]->type eq sales_order_type() ? 'ordnumber' - : $_[0]->type eq purchase_order_type() ? 'ordnumber' - : $_[0]->type eq sales_quotation_type() ? 'quonumber' - : $_[0]->type eq request_quotation_type() ? 'quonumber' - : ''; + my ($self) = @_; + + return $self->type_data->properties('nr_key'); } sub save_and_redirect_to { my ($self, %params) = @_; - my $errors = $self->save(); - - if (scalar @{ $errors }) { - $self->js->flash('error', $_) foreach @{ $errors }; - return $self->js->render(); - } + $self->save(); - my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved') - : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved') - : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved') - : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved') - : ''; - flash_later('info', $text); + flash_later('info', $self->type_data->text('saved')); $self->redirect_to(%params, id => $self->order->id); } @@ -2655,16 +2855,19 @@ sub store_doc_to_webdav_and_filemanagement { push @errors, t8('Storing the document to the WebDAV folder failed: #1', $@); }; } + my $file_obj; if ($order->id && $::instance_conf->get_doc_storage) { eval { - SL::File->save(object_id => $order->id, - object_type => $order->type, - mime_type => SL::MIME->mime_type_from_ext($filename), - source => 'created', - file_type => 'document', - file_name => $filename, - file_contents => $content, - print_variant => $variant); + $file_obj = SL::File->save(object_id => $order->id, + object_type => $order->type, + mime_type => SL::MIME->mime_type_from_ext($filename), + source => 'created', + file_type => 'document', + file_name => $filename, + file_contents => $content, + print_variant => $variant); + + $self->{file_id} = $file_obj->id; 1; } or do { push @errors, t8('Storing the document in the storage backend failed: #1', $@); @@ -2674,30 +2877,9 @@ sub store_doc_to_webdav_and_filemanagement { return @errors; } -sub link_requirement_specs_linking_to_created_from_objects { - my ($self, @converted_from_oe_ids) = @_; - - return unless @converted_from_oe_ids; - - my $rs_orders = SL::DB::Manager::RequirementSpecOrder->get_all(where => [ order_id => \@converted_from_oe_ids ]); - foreach my $rs_order (@{ $rs_orders }) { - SL::DB::RequirementSpecOrder->new( - order_id => $self->order->id, - requirement_spec_id => $rs_order->requirement_spec_id, - version_id => $rs_order->version_id, - )->save; - } -} - -sub set_project_in_linked_requirement_specs { +sub init_type_data { my ($self) = @_; - - my $rs_orders = SL::DB::Manager::RequirementSpecOrder->get_all(where => [ order_id => $self->order->id ]); - foreach my $rs_order (@{ $rs_orders }) { - next if $rs_order->requirement_spec->project_id == $self->order->globalproject_id; - - $rs_order->requirement_spec->update_attributes(project_id => $self->order->globalproject_id); - } + SL::DB::Helper::TypeDataProxy->new('SL::DB::Order', $self->order->record_type); } 1; @@ -2880,7 +3062,7 @@ editor or on text processing application). =item * -A warning when leaving the page without saveing unchanged inputs. +A warning when leaving the page without saving unchanged inputs. =back diff --git a/SL/Controller/OrderItem.pm b/SL/Controller/OrderItem.pm index 7cdfb3555..d7ee7f333 100644 --- a/SL/Controller/OrderItem.pm +++ b/SL/Controller/OrderItem.pm @@ -84,7 +84,7 @@ sub init_model { } sub check_auth { - $::auth->assert('sales_order_edit'); + $::auth->assert('sales_order_item_search'); } 1; diff --git a/SL/Controller/Part.pm b/SL/Controller/Part.pm index aae4e531e..8965377e7 100644 --- a/SL/Controller/Part.pm +++ b/SL/Controller/Part.pm @@ -3,42 +3,53 @@ package SL::Controller::Part; use strict; use parent qw(SL::Controller::Base); +use Carp; use Clone qw(clone); +use Data::Dumper; +use DateTime; +use File::Temp; +use List::Util qw(sum); +use List::UtilsBy qw(extract_by); +use POSIX qw(strftime); +use Text::CSV_XS; + +use SL::CVar; +use SL::Controller::Helper::GetModels; +use SL::DB::Business; +use SL::DB::BusinessModel; +use SL::DB::Helper::ValidateAssembly qw(validate_assembly); +use SL::DB::History; use SL::DB::Part; use SL::DB::PartsGroup; use SL::DB::PriceRuleItem; +use SL::DB::PurchaseBasketItem; use SL::DB::Shop; -use SL::Controller::Helper::GetModels; -use SL::Locale::String qw(t8); -use SL::JSON; -use List::Util qw(sum); -use List::UtilsBy qw(extract_by); use SL::Helper::Flash; -use Data::Dumper; -use DateTime; -use SL::DB::History; -use SL::DB::Helper::ValidateAssembly qw(validate_assembly); -use SL::CVar; +use SL::Helper::PrintOptions; +use SL::Helper::UserPreferences::PartPickerSearch; +use SL::JSON; +use SL::Locale::String qw(t8); use SL::MoreCommon qw(save_form); -use Carp; use SL::Presenter::EscapedText qw(escape is_escaped); +use SL::Presenter::Part; use SL::Presenter::Tag qw(select_tag); use Rose::Object::MakeMethods::Generic ( 'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models - makemodels shops_not_assigned + makemodels businessmodels shops_not_assigned customerprices orphaned assortment assortment_items assembly assembly_items all_pricegroups all_translations all_partsgroups all_units all_buchungsgruppen all_payment_terms all_warehouses parts_classification_filter - all_languages all_units all_price_factors) ], + all_languages all_units all_price_factors + all_businesses) ], 'scalar' => [ qw(warehouse bin stock_amounts journal) ], ); # safety -__PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') }, +__PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit', 1) || $::auth->assert('part_service_assembly_details') }, except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]); __PACKAGE__->run_before(sub { $::auth->assert('developer') }, @@ -125,10 +136,24 @@ sub action_save { my @errors = $self->part->validate; return $self->js->error(@errors)->render if @errors; + if ($is_new) { + # Ensure CVars that should be enabled by default actually are when + # creating new parts. + my @default_valid_configs = + grep { ! $_->{flag_defaults_to_invalid} } + grep { $_->{module} eq 'IC' } + @{ CVar->get_configs() }; + + $::form->{"cvar_" . $_->{name} . "_valid"} = 1 for @default_valid_configs; + } else { + $self->{lastcost_modified} = $self->check_lastcost_modified; + } + # $self->part has been loaded, parsed and validated without errors and is ready to be saved $self->part->db->with_transaction(sub { $self->part->save(cascade => 1); + $self->part->set_lastcost_assemblies_and_assortiments if $self->{lastcost_modified}; SL::DB::History->new( trans_id => $self->part->id, @@ -227,10 +252,15 @@ sub action_use_as_new { $::form->{oldpartnumber} = $oldpart->partnumber; $self->part($oldpart->clone_and_reset_deep); - $self->parse_form; + $self->parse_form(use_as_new => 1); $self->part->partnumber(undef); - $self->render_form; + if (!$::auth->assert('part_service_assembly_edit_prices', 'may_fail')) { + # No right to edit prices -> remove prices for new part. + $self->part->$_(undef) for qw(sellprice lastcost listprice); + } + + $self->render_form(use_as_new => 1); } sub action_edit { @@ -239,6 +269,30 @@ sub action_edit { $self->render_form; } +sub action_add_to_basket { + my ( $self ) = @_; + + if ( !$self->_is_in_purchase_basket && scalar @{$self->part->makemodels}) { + + my $part = $self->part; + + my $needed_qty = $part->order_qty < ($part->rop - $part->onhandqty) ? + $part->rop - $part->onhandqty + : $part->order_qty; + + my $basket_part = SL::DB::PurchaseBasketItem->new( + part_id => $part->id, + qty => $needed_qty, + orderer => SL::DB::Manager::Employee->current, + )->save; + + $self->js->flash('info', t8('Part added to purchasebasket'))->render; + } else { + $self->js->flash('error', t8('Part already in purchasebasket or has no vendor'))->render; + } + return 1; +} + sub render_form { my ($self, %params) = @_; @@ -249,7 +303,10 @@ sub render_form { %assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment; %assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly; - $params{CUSTOM_VARIABLES} = CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id); + $params{CUSTOM_VARIABLES} = $params{use_as_new} && $::form->{old_id} + ? CVar->get_custom_variables(module => 'IC', trans_id => $::form->{old_id}) + : CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id); + if (scalar @{ $params{CUSTOM_VARIABLES} }) { CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id); @@ -482,11 +539,13 @@ sub action_add_makemodel_row { my $position = scalar @{$self->makemodels} + 1; - my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id}, - make => $vendor->id, - model => '', - lastcost => 0, - sortorder => $position, + my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id}, + make => $vendor->id, + model => '', + part_description => '', + part_longdescription => '', + lastcost => 0, + sortorder => $position, ) or die "Can't create MakeModel object"; my $row_as_html = $self->p->render('part/_makemodel_row', @@ -502,6 +561,42 @@ sub action_add_makemodel_row { ->render; } +sub action_add_businessmodel_row { + my ($self) = @_; + + my $business_id = $::form->{add_businessmodel}; + + my $business = SL::DB::Manager::Business->find_by(id => $business_id) or + return $self->js->error(t8("No business selected or found!"))->render; + + if ( grep { $business_id == $_->business_id } @{ $self->businessmodels } ) { + return $self->js + ->scroll_into_view('#content') + ->flash('error', (t8("This business has already been added."))) + ->render; + }; + + my $position = scalar @{ $self->businessmodels } + 1; + + my $bm = SL::DB::BusinessModel->new(#parts_id => $::form->{part}->{id}, + business => $business, + model => '', + part_description => '', + part_longdescription => '', + position => $position, + ) or die "Can't create BusinessModel object"; + + my $row_as_html = $self->p->render('part/_businessmodel_row', + businessmodel => $bm); + + # after selection focus on the model field in the row that was just added + $self->js + ->append('#businessmodel_rows', $row_as_html) # append in tbody + ->val('#add_businessmodel', '') + ->run('kivi.Part.focus_last_businessmodel_input') + ->render; +} + sub action_add_customerprice_row { my ($self) = @_; @@ -517,10 +612,12 @@ sub action_add_customerprice_row { my $position = scalar @{ $self->customerprices } + 1; my $cu = SL::DB::PartCustomerPrice->new( - customer_id => $customer->id, - customer_partnumber => '', - price => 0, - sortorder => $position, + customer_id => $customer->id, + customer_partnumber => '', + part_description => '', + part_longdescription => '', + price => 0, + sortorder => $position, ) or die "Can't create Customerprice object"; my $row_as_html = $self->p->render( @@ -584,7 +681,7 @@ sub action_warehouse_changed { die unless ref($self->warehouse) eq 'SL::DB::Warehouse'; if ( $self->warehouse->id and @{$self->warehouse->bins} ) { - $self->bin($self->warehouse->bins_sorted->[0]); + $self->bin($self->warehouse->bins_sorted_naturally->[0]); $self->js ->html('#bin', $self->build_bin_select) ->focus('#part_bin_id'); @@ -652,7 +749,9 @@ sub action_part_picker_search { $search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike}; $search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike}; - $_[0]->render('part/part_picker_search', { layout => 0 }, search_term => $search_term); + my $all_as_list = SL::Helper::UserPreferences::PartPickerSearch->new()->get_all_as_list_default; + + $_[0]->render('part/part_picker_search', { layout => 0 }, search_term => $search_term, all_as_list => $all_as_list); } sub action_part_picker_result { @@ -675,6 +774,157 @@ sub action_show { } } +sub action_showdetails { + my ($self, %params) = @_; + + my @bindata; + my $bins = SL::DB::Manager::Bin->get_all(with_objects => ['warehouse' ]); + my %bins_by_id = map { $_->id => $_ } @$bins; + my $inventories = SL::DB::Manager::Inventory->get_all(where => [ parts_id => $self->part->id], + with_objects => ['parts', 'trans_type' ], sort_by => 'bin_id ASC'); + foreach my $bin (@{ $bins }) { + $bin->{qty} = 0; + } + + foreach my $inv (@{ $inventories }) { + my $bin = $bins_by_id{ $inv->bin_id }; + $bin->{qty} += $inv->qty; + $bin->{unit} = $inv->parts->unit; + } + my $sum = 0; + for my $bin (@{ $bins }) { + push @bindata , { + 'warehouse' => $bin->warehouse->description, + 'description' => $bin->description, + 'qty' => $bin->{qty}, + 'unit' => $bin->{unit}, + } if $bin->{qty} != 0; + + $sum += $bin->{qty}; + } + + my $todate = DateTime->now_local; + my $fromdate = DateTime->now_local->add_duration(DateTime::Duration->new(years => -1)); + my $average = 0; + foreach my $inv (@{ $inventories }) { + $average += abs($inv->qty) if $inv->shippingdate && $inv->trans_type->direction eq 'out' && + DateTime->compare($inv->shippingdate,$fromdate) != -1 && + DateTime->compare($inv->shippingdate,$todate) == -1; + } + my $openitems = SL::DB::Manager::OrderItem->get_all(where => [ parts_id => $self->part->id, 'order.closed' => 0 ], + with_objects => ['order'],); + my ($not_delivered, $ordered) = 0; + for my $openitem (@{ $openitems }) { + if($openitem -> order -> type eq 'sales_order') { + $not_delivered += $openitem->qty - $openitem->shipped_qty; + } elsif ( $openitem->order->type eq 'purchase_order' ) { + $ordered += $openitem->qty - $openitem->delivered_qty; + } + } + + my $stock_amounts = $self->part->get_simple_stock_sql; + + my $output = SL::Presenter->get->render('part/showdetails', + part => $self->part, + stock_amounts => $stock_amounts, + average => $average/12, + fromdate => $fromdate, + todate => $todate, + sum => $sum, + not_delivered => $not_delivered, + ordered => $ordered, + print_options => SL::Helper::PrintOptions->get_print_options( + form => Form->new( + type => 'part', + printers => SL::DB::Manager::Printer->get_all_sorted, + ), + options => { + dialog_name_prefix => 'print_options.', + show_headers => 1, + no_queue => 1, + no_postscript => 1, + no_opendocument => 1, + hide_language_id_print => 1, + no_html => 1, + }, + ), + ); + $self->render(\$output, { layout => 0, process => 0 }); +} + +sub action_print_label { + my ($self) = @_; + # TODO: implement + return $self->render('generic/error', { layout => 1 }, label_error => t8('Not implemented yet!')); +} + +sub action_export_assembly_assortment_components { + my ($self) = @_; + + my $bom_or_charge = $self->part->is_assembly ? 'bom' : 'charge'; + + my @rows = ([ + $::locale->text('Partnumber'), + $::locale->text('Description'), + $::locale->text('Type'), + $::locale->text('Classification'), + $::locale->text('Qty'), + $::locale->text('Unit'), + $self->part->is_assembly ? $::locale->text('BOM') : $::locale->text('Charge'), + $::locale->text('Line Total'), + $::locale->text('Sellprice'), + $::locale->text('Lastcost'), + $::locale->text('Partsgroup'), + ]); + + foreach my $item (@{ $self->part->items }) { + my $part = $item->part; + + my @row = ( + $part->partnumber, + $part->description, + SL::Presenter::Part::type_abbreviation($part->part_type), + SL::Presenter::Part::classification_abbreviation($part->classification_id), + $item->qty_as_number, + $part->unit, + $item->$bom_or_charge ? $::locale->text('yes') : $::locale->text('no'), + $::form->format_amount(\%::myconfig, $item->linetotal_sellprice, 3, 0), + $part->sellprice_as_number, + $part->lastcost_as_number, + $part->partsgroup ? $part->partsgroup->partsgroup : '', + ); + + push @rows, \@row; + } + + my $csv = Text::CSV_XS->new({ + sep_char => ';', + eol => "\n", + binary => 1, + }); + + my ($file_handle, $file_name) = File::Temp::tempfile; + + binmode $file_handle, ":encoding(utf8)"; + + $csv->print($file_handle, $_) for @rows; + + $file_handle->close; + + my $type_prefix = $self->part->is_assembly ? 'assembly' : 'assortment'; + my $part_number = $self->part->partnumber; + $part_number =~ s{[^[:word:]]+}{_}g; + my $timestamp = strftime('_%Y-%m-%d_%H-%M-%S', localtime()); + my $attachment_name = sprintf('%s_components_%s_%s.csv', $type_prefix, $part_number, $timestamp); + + $self->send_file( + $file_name, + content_type => 'text/csv', + name => $attachment_name, + ); + +} + # helper functions sub validate_add_items { scalar @{$::form->{add_items}}; @@ -729,7 +979,7 @@ sub add { sub _set_javascript { my ($self) = @_; - $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart kivi.Validator); + $::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule kivi.ShopPart kivi.Validator); $::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id; } @@ -775,13 +1025,25 @@ sub check_part_not_modified { } -sub parse_form { +sub check_lastcost_modified { my ($self) = @_; + return (abs($self->part->lastcost - $self->part->last_price_update->lastcost) >= 0.009) + || (abs(($self->part->price_factor ? $self->part->price_factor->factor : 1) - $self->part->last_price_update->price_factor) >= 0.009); +} + +sub parse_form { + my ($self, %params) = @_; + my $is_new = !$self->part->id; my $params = delete($::form->{part}) || { }; + if (!$::auth->assert('part_service_assembly_edit_prices', 'may_fail')) { + # No right to set or change prices, so delete prices from params. + delete $params->{$_} for qw(sellprice_as_number lastcost_as_number listprice_as_number); + } + delete $params->{id}; $self->part->assign_attributes(%{ $params}); $self->part->bin_id(undef) unless $self->part->warehouse_id; @@ -801,14 +1063,23 @@ sub parse_form { $self->part->add_assemblies( @{ $self->assembly_items } ); }; - $self->part->translations([]); + # Update lastcost for assemblies + if ($self->part->is_assembly) { + my $lastcost_sum = $self->recalc_item_totals(part_type => $self->part->part_type, price_type => 'lastcost'); + $self->part->lastcost($lastcost_sum); + } + + $self->part->translations([]) unless $params{use_as_new}; $self->parse_form_translations; - $self->part->prices([]); - $self->parse_form_prices; + if ($::auth->assert('part_service_assembly_edit_prices', 'may_fail')) { + $self->part->prices([]); + $self->parse_form_prices; + } $self->parse_form_customerprices; $self->parse_form_makemodels; + $self->parse_form_businessmodels; } sub parse_form_prices { @@ -855,13 +1126,21 @@ sub parse_form_makemodels { my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make"; my $id = $makemodels_map->{$makemodel->{id}} ? $makemodels_map->{$makemodel->{id}}->id : undef; - my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels - id => $id, - make => $makemodel->{make}, - model => $makemodel->{model} || '', - lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}), - sortorder => $position, + my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels + id => $id, + make => $makemodel->{make}, + model => $makemodel->{model} || '', + part_description => $makemodel->{part_description}, + part_longdescription => $makemodel->{part_longdescription}, + lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}), + sortorder => $position, ); + + if (!$::auth->assert('part_service_assembly_edit_prices', 'may_fail')) { + # No right to edit prices -> restore old lastcost. + $mm->lastcost($makemodels_map->{$id} ? $makemodels_map->{$id}->lastcost : undef); + } + if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) { # lastupdate isn't set, original lastcost is 0 and new lastcost is 0 # don't change lastupdate @@ -878,6 +1157,36 @@ sub parse_form_makemodels { }; } +sub parse_form_businessmodels { + my ($self) = @_; + + my $make_key = sub { return $_[0]->parts_id . '+' . $_[0]->business_id; }; + + my $businessmodels_map; + if ( $self->part->businessmodels ) { # check for new parts or parts without businessmodels + $businessmodels_map = { map { $make_key->($_) => Rose::DB::Object::Helpers::clone($_) } @{$self->part->businessmodels} }; + }; + + $self->part->businessmodels([]); + + my $position = 0; + my $businessmodels = delete($::form->{businessmodels}) || []; + foreach my $businessmodel ( @{$businessmodels} ) { + next unless $businessmodel->{business_id}; + + $position++; + my $bm = SL::DB::BusinessModel->new( #parts_id => $self->part->id, # will be assigned by row add_businessmodels + business_id => $businessmodel->{business_id}, + model => $businessmodel->{model} || '', + part_description => $businessmodel->{part_description} || '', + part_longdescription => $businessmodel->{part_longdescription} || '', + position => $position, + ); + + $self->part->add_businessmodels($bm); + }; +} + sub parse_form_customerprices { my ($self) = @_; @@ -900,9 +1209,17 @@ sub parse_form_customerprices { id => $id, customer_id => $customerprice->{customer_id}, customer_partnumber => $customerprice->{customer_partnumber} || '', + part_description => $customerprice->{part_description}, + part_longdescription => $customerprice->{part_longdescription}, price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number}), sortorder => $position, ); + + if (!$::auth->assert('part_service_assembly_edit_prices', 'may_fail')) { + # No right to edit prices -> restore old price. + $cu->price($customerprices_map->{$id} ? $customerprices_map->{$id}->price : undef); + } + if ($customerprices_map->{$cu->id} && !$customerprices_map->{$cu->id}->lastupdate && $customerprices_map->{$cu->id}->price == 0 && $cu->price == 0) { # lastupdate isn't set, original price is 0 and new lastcost is 0 # don't change lastupdate @@ -919,7 +1236,7 @@ sub parse_form_customerprices { } sub build_bin_select { - select_tag('part.bin_id', [ @{ $_[0]->warehouse->bins_sorted } ], + select_tag('part.bin_id', [ @{ $_[0]->warehouse->bins_sorted_naturally } ], title_key => 'description', default => $_[0]->bin->id, ); @@ -943,7 +1260,7 @@ sub init_part { # used by edit, save, delete and add if ( $::form->{part}{id} ) { - return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]); + return SL::DB::Part->new(id => $::form->{part}{id})->load(with => [ qw(makemodels businessmodels customerprices prices translations partsgroup shop_parts shop_parts.shop) ]); } elsif ( $::form->{id} ) { return SL::DB::Part->new(id => $::form->{id})->load; # used by inventory tab } else { @@ -1012,11 +1329,13 @@ sub init_makemodels { next unless $makemodel->{make}; $position++; my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels - id => $makemodel->{id}, - make => $makemodel->{make}, - model => $makemodel->{model} || '', - lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0), - sortorder => $position, + id => $makemodel->{id}, + make => $makemodel->{make}, + model => $makemodel->{model} || '', + part_description => $makemodel->{part_description} || '', + part_longdescription => $makemodel->{part_longdescription} || '', + lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number} || 0), + sortorder => $position, ) or die "Can't create mm"; # $mm->id($makemodel->{id}) if $makemodel->{id}; push(@makemodel_array, $mm); @@ -1024,6 +1343,28 @@ sub init_makemodels { return \@makemodel_array; } +sub init_businessmodels { + my ($self) = @_; + + my @businessmodel_array = (); + my $businessmodels = delete($::form->{businessmodels}) || []; + + foreach my $businessmodel ( @{$businessmodels} ) { + next unless $businessmodel->{business_id}; + + my $bm = SL::DB::BusinessModel->new(#parts_id => $self->part->id, # will be assigned by row add_businessmodels + business_id => $businessmodel->{business_id}, + model => $businessmodel->{model} || '', + part_description => $businessmodel->{part_description} || '', + part_longdescription => $businessmodel->{part_longdescription} || '', + ) or die "Can't create bm"; + + push(@businessmodel_array, $bm); + }; + + return \@businessmodel_array; +} + sub init_customerprices { my ($self) = @_; @@ -1035,11 +1376,13 @@ sub init_customerprices { next unless $customerprice->{customer_id}; $position++; my $cu = SL::DB::PartCustomerPrice->new( # parts_id => $self->part->id, # will be assigned by row add_customerprices - id => $customerprice->{id}, - customer_partnumber => $customerprice->{customer_partnumber}, - customer_id => $customerprice->{customer_id} || '', - price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0), - sortorder => $position, + id => $customerprice->{id}, + customer_partnumber => $customerprice->{customer_partnumber}, + customer_id => $customerprice->{customer_id} || '', + part_description => $customerprice->{part_description}, + part_longdescription => $customerprice->{part_longdescription}, + price => $::form->parse_amount(\%::myconfig, $customerprice->{price_as_number} || 0), + sortorder => $position, ) or die "Can't create cu"; # $cu->id($customerprice->{id}) if $customerprice->{id}; push(@customerprice_array, $cu); @@ -1082,11 +1425,18 @@ sub init_all_partsgroups { sub init_all_buchungsgruppen { my ($self) = @_; - if ( $self->part->orphaned ) { - return SL::DB::Manager::Buchungsgruppe->get_all_sorted; - } else { + if (!$self->part->orphaned) { return SL::DB::Manager::Buchungsgruppe->get_all_sorted(where => [ id => $self->part->buchungsgruppen_id ]); } + + return SL::DB::Manager::Buchungsgruppe->get_all_sorted( + where => [ + or => [ + id => $self->part->buchungsgruppen_id, + obsolete => 0, + ], + ] + ); } sub init_shops_not_assigned { @@ -1123,6 +1473,10 @@ sub init_all_pricegroups { SL::DB::Manager::Pricegroup->get_all_sorted(query => [ obsolete => 0 ]); } +sub init_all_businesses { + SL::DB::Manager::Business->get_all_sorted; +} + # model used to filter/display the parts in the multi-items dialog sub init_multi_items_models { SL::Controller::Helper::GetModels->new( @@ -1230,6 +1584,21 @@ sub form_check_partnumber_is_unique { return 1; } +sub form_check_buchungsgruppe { + my ($self) = @_; + + return 1 if $::form->{part}->{obsolete}; + + my $buchungsgruppe = SL::DB::Buchungsgruppe->new(id => $::form->{part}->{buchungsgruppen_id})->load; + + return 1 if !$buchungsgruppe->obsolete; + + $self->js->flash('error', t8("The booking group '#1' is obsolete and cannot be used with active articles.", $buchungsgruppe->description)) + ->focus('#part_buchungsgruppen_id'); + + return 0; +} + # general checking functions sub check_part_id { @@ -1244,6 +1613,7 @@ sub check_form { $self->form_check_assortment_items_unique || return 0; $self->form_check_assembly_items_exist || return 0; $self->form_check_partnumber_is_unique || return 0; + $self->form_check_buchungsgruppe || return 0; return 1; } @@ -1342,6 +1712,18 @@ sub parse_add_items_to_objects { return \@item_objects; } +sub _is_in_purchase_basket { + my ( $self ) = @_; + + return SL::DB::Manager::PurchaseBasketItem->get_all_count( query => [ part_id => $self->part->id ] ); +} + +sub _is_ordered { + my ( $self ) = @_; + + return $self->part->get_ordered_qty( $self->part->id ); +} + sub _setup_form_action_bar { my ($self) = @_; @@ -1375,11 +1757,29 @@ sub _setup_form_action_bar { disabled => !$self->part->id ? t8('The object has not been saved yet.') : !$may_edit ? t8('You do not have the permissions to access this function.') : !$::auth->assert('purchase_order_edit', 'may fail') ? t8('You do not have the permissions to access this function.') + : $self->part->order_locked ? t8('This part should not be ordered any more.') : undef, only_if => !$::form->{inline_create}, ], ], + combobox => [ + action => [ + t8('Export'), + only_if => $self->part->is_assembly || $self->part->is_assortment, + ], + action => [ + $self->part->is_assembly ? t8('Assembly items') : t8('Assortment items'), + submit => [ '#ic', { action => "Part/export_assembly_assortment_components" } ], + checks => ['kivi.validate_form'], + disabled => !$self->part->id ? t8('The object has not been saved yet.') + : !$may_edit ? t8('You do not have the permissions to access this function.') + : !$::auth->assert('purchase_order_edit', 'may fail') ? t8('You do not have the permissions to access this function.') + : undef, + only_if => $self->part->is_assembly || $self->part->is_assortment, + ], + ], + action => [ t8('Abort'), submit => [ '#ic', { action => "Part/abort" } ], @@ -1397,6 +1797,16 @@ sub _setup_form_action_bar { : undef, ], + action => [ + t8('Add to basket'), + call => [ 'kivi.Part.add_to_basket' ], + disabled => !$self->part->id ? t8('This object has not been saved yet.') + : $self->_is_in_purchase_basket ? t8('Part already in purchasebasket') + : $self->_is_ordered ? t8('Part already ordered') + : !scalar @{$self->part->makemodels} ? t8('No vendors to add to purchasebasket') + : undef, + ], + 'separator', action => [ diff --git a/SL/Controller/PartsPriceHistory.pm b/SL/Controller/PartsPriceHistory.pm index 82263b876..7cf59fe8d 100644 --- a/SL/Controller/PartsPriceHistory.pm +++ b/SL/Controller/PartsPriceHistory.pm @@ -70,10 +70,11 @@ sub column_defs { my ($self) = @_; return { - valid_from => { text => $::locale->text('Date'), sub => sub { $_[0]->valid_from_as_timestamp }}, - lastcost => { text => $::locale->text('Lastcost'), sub => sub { $_[0]->lastcost_as_number }}, - listprice => { text => $::locale->text('List Price'), sub => sub { $_[0]->listprice_as_number }}, - sellprice => { text => $::locale->text('Sell Price'), sub => sub { $_[0]->sellprice_as_number }}, + valid_from => { text => $::locale->text('Date'), sub => sub { $_[0]->valid_from_as_timestamp }}, + lastcost => { text => $::locale->text('Lastcost'), sub => sub { $_[0]->lastcost_as_number }}, + listprice => { text => $::locale->text('List Price'), sub => sub { $_[0]->listprice_as_number }}, + sellprice => { text => $::locale->text('Sell Price'), sub => sub { $_[0]->sellprice_as_number }}, + price_factor => { text => $::locale->text('Price Factor'), sub => sub { $_[0]->price_factor_as_number }}, }; } @@ -86,7 +87,7 @@ sub prepare_report { my $title = $::locale->text('Price history for master data'); - my @columns = qw(valid_from lastcost listprice sellprice); + my @columns = qw(valid_from lastcost listprice sellprice price_factor); my $column_defs = $self->column_defs; diff --git a/SL/Controller/PartsPriceUpdate.pm b/SL/Controller/PartsPriceUpdate.pm index 8481998d4..738f29693 100644 --- a/SL/Controller/PartsPriceUpdate.pm +++ b/SL/Controller/PartsPriceUpdate.pm @@ -92,7 +92,7 @@ sub _create_filter_for_priceupdate { my @where_values; my $where = '1 = 1'; - for my $item (qw(partnumber drawing microfiche make model pg.partsgroup description serialnumber)) { + for my $item (qw(partnumber drawing microfiche pg.partsgroup description serialnumber)) { my $column = $item; $column =~ s/.*\.//; next unless $filter->{$column}; @@ -130,10 +130,14 @@ sub _create_filter_for_priceupdate { } - for my $column (qw(make model)) { - next unless ($filter->{$column}); - $where .= qq| AND p.id IN (SELECT DISTINCT parts_id FROM makemodel WHERE $column ILIKE ?|; - push @where_values, "%$filter->{$column}%"; + if ($filter->{make}) { + $where .= qq| AND p.id IN (SELECT DISTINCT parts_id FROM makemodel WHERE make = ?) |; + push @where_values, $filter->{make}; + } + + if ($filter->{model}) { + $where .= qq| AND p.id IN (SELECT DISTINCT parts_id FROM makemodel WHERE model ILIKE ?) |; + push @where_values, "%$filter->{model}%"; } return ($where, @where_values); @@ -273,7 +277,7 @@ sub init_pricegroups_by_id { } sub check_rights { - $::auth->assert('part_service_assembly_edit'); + $::auth->assert('part_service_assembly_edit & part_service_assembly_edit_prices'); } sub init_filter { diff --git a/SL/Controller/PayPostingImport.pm b/SL/Controller/PayPostingImport.pm index c8d39091d..733dc5d28 100644 --- a/SL/Controller/PayPostingImport.pm +++ b/SL/Controller/PayPostingImport.pm @@ -17,7 +17,15 @@ sub action_upload_pay_postings { my ($self, %params) = @_; $self->setup_pay_posting_action_bar; - $self->render('pay_posting_import/form', title => $::locale->text('Import Pay Postings')); + + # new closedto + my $today = DateTime->now(); + $today->subtract(months => 1); + + my $dt = DateTime->last_day_of_month(year => $today->year, month => $today->month); + + my $new_closedto = $dt->to_kivitendo(); + $self->render('pay_posting_import/form', title => $::locale->text('Import Pay Postings'), closedto => $new_closedto); } sub action_import_datev_pay_postings { @@ -39,6 +47,9 @@ sub action_import_datev_pay_postings { if (parse_and_import($self)) { flash_later('info', t8("All pay postings successfully imported.")); } + if ($::form->{set_closedto} && _set_closedto($self)) { + flash_later('info', t8("Books closed until:") . ' ' . $::form->{closedto}); + } $self->setup_pay_posting_action_bar; $self->render('pay_posting_import/form', title => $::locale->text('Imported Pay Postings')); } @@ -124,6 +135,19 @@ sub parse_and_import { }) or do { die t8("Cannot add Booking, reason: #1 DB: #2 ", $@, SL::DB->client->error) }; } + +sub _set_closedto { + my $self = shift; + die "no date:" . $::form->{closedto} unless $::form->{closedto}; + + my $defaults = SL::DB::Default->get; + + $defaults->closedto(DateTime->from_kivitendo($::form->{closedto})); + $defaults->save || die "Cannot save closedto!"; + + return 1; +} + sub check_auth { $::auth->assert('general_ledger'); } diff --git a/SL/Controller/PriceRule.pm b/SL/Controller/PriceRule.pm index 0579c5349..6b7ec06b4 100644 --- a/SL/Controller/PriceRule.pm +++ b/SL/Controller/PriceRule.pm @@ -17,7 +17,7 @@ use SL::Locale::String; use Rose::Object::MakeMethods::Generic ( - 'scalar --get_set_init' => [ qw(models price_rule vc pricegroups partsgroups businesses) ], + 'scalar --get_set_init' => [ qw(models price_rule vc pricegroups partsgroups businesses cvar_configs) ], ); # __PACKAGE__->run_before('check_auth'); @@ -80,7 +80,9 @@ sub action_destroy { sub action_add_item_row { my ($self, %params) = @_; - my $item = SL::DB::PriceRuleItem->new(type => $::form->{type}); + my $item = $::form->{type} =~ m{cvar/(\d+)} + ? SL::DB::PriceRuleItem->new(type => 'cvar', custom_variable_configs_id => $1) + : SL::DB::PriceRuleItem->new(type => $::form->{type}); my $html = $self->render('price_rule/item', { output => 0 }, item => $item); @@ -189,6 +191,10 @@ sub prepare_report { if ( $report->{options}{output_format} =~ /^(pdf|csv)$/i ) { $self->models->disable_plugin('paginated'); } + + my $title = t8('Price Rules'); + $report->{title} = $title; #for browser titlebar (title-tag) + $report->set_options( std_column_visibility => 1, controller_class => 'PriceRule', @@ -235,7 +241,10 @@ sub make_filter_summary { } sub all_price_rule_item_types { - SL::DB::Manager::PriceRuleItem->get_all_types($_[0]->vc || $_[0]->price_rule->type); + my $item_types = SL::DB::Manager::PriceRuleItem->get_all_types($_[0]->vc || $_[0]->price_rule->type); + my @cvar_types = map [ "cvar/" . $_->id, $_->presenter->description_with_module ], @{$_[0]->cvar_configs }; + + [ @$item_types, @cvar_types ]; } sub add_javascripts { @@ -255,7 +264,9 @@ sub init_price_rule { my @items; for my $raw_item (@$items) { - my $item = $raw_item->{id} ? $old_items{ $raw_item->{id} } || SL::DB::PriceRuleItem->new(id => $raw_item->{id})->load : SL::DB::PriceRuleItem->new; + my $item = $raw_item->{id} + ? $old_items{ $raw_item->{id} } || SL::DB::PriceRuleItem->new(id => $raw_item->{id})->load + : SL::DB::PriceRuleItem->new; $item->assign_attributes(%$raw_item); push @items, $item; } @@ -281,6 +292,16 @@ sub init_partsgroups { SL::DB::Manager::PartsGroup->get_all; } +sub init_cvar_configs { + # eligible cvars for this are all that are reachable from a record or recorditem (all modules but requirement spec) + # and of a type that price rules support (currently: id-based with picker, numeric or date) and by special request select + SL::DB::Manager::CustomVariableConfig->get_all(where => [ + "!module" => 'RequirementSpecs', + type => [ qw(timestamp date number integer customer vendor part select) ], + ]) ; +} + + sub all_price_types { SL::DB::Manager::PriceRule->all_price_types; } diff --git a/SL/Controller/PriceSource.pm b/SL/Controller/PriceSource.pm index c0c113cd0..2304948f1 100644 --- a/SL/Controller/PriceSource.pm +++ b/SL/Controller/PriceSource.pm @@ -126,6 +126,8 @@ sub _make_record_item { $obj->${\"$method\_as_date"}($value); } elsif ((ref $obj->meta->column($method)) =~ /^Rose::DB::Object::Metadata::Column::(?:Numeric|Float|DoublePrecsion)$/) { $obj->${\"$method\_as_number"}($value); + } elsif ((ref $obj->meta->column($method)) =~ /^Rose::DB::Object::Metadata::Column::(?:Big)?(?:Int(?:eger)?|Serial)$/) { + $obj->$method(($value // '') eq '' ? undef : $value * 1); } elsif ((ref $obj->meta->column($method)) =~ /^Rose::DB::Object::Metadata::Column::Boolean$/) { $obj->$method(!!$value); } else { @@ -180,6 +182,8 @@ sub _make_record { $obj->${\"$method\_as_date"}($::form->{$method}); } elsif ((ref $obj->meta->column($method)) =~ /^Rose::DB::Object::Metadata::Column::(?:Numeric|Float|DoublePrecsion)$/) { $obj->${\"$method\_as\_number"}($::form->{$method}); + } elsif ((ref $obj->meta->column($method)) =~ /^Rose::DB::Object::Metadata::Column::(?:Big)?(?:Int(?:eger)?|Serial)$/) { + $obj->$method(($::form->{$method} // '') eq '' ? undef : $::form->{$method} * 1) } elsif ((ref $obj->meta->column($method)) =~ /^Rose::DB::Object::Metadata::Column::Boolean$/) { $obj->$method(!!$::form->{$method}); } else { diff --git a/SL/Controller/Project.pm b/SL/Controller/Project.pm index 702db8907..351c5d35a 100644 --- a/SL/Controller/Project.pm +++ b/SL/Controller/Project.pm @@ -248,7 +248,7 @@ sub display_form { CVar->render_inputs(variables => $params{CUSTOM_VARIABLES}) if @{ $params{CUSTOM_VARIABLES} }; - $::request->layout->use_javascript("$_.js") for qw(kivi.File ckeditor/ckeditor ckeditor/adapters/jquery); + $::request->layout->use_javascript("$_.js") for qw(kivi.File ckeditor5/ckeditor ckeditor5/translations/de); $self->setup_edit_action_bar(callback => $params{callback}); $self->render('project/form', %params); @@ -304,8 +304,9 @@ sub prepare_report { my $callback = $self->models->get_callback; - my $report = SL::ReportGenerator->new(\%::myconfig, $::form); - $self->{report} = $report; + my $report = SL::ReportGenerator->new(\%::myconfig, $::form); + $report->{title} = t8('Projects'); + $self->{report} = $report; my @columns = qw(project_status customer projectnumber description active valid project_type); my @sortable = qw(projectnumber description customer project_type project_status); diff --git a/SL/Controller/Reclamation.pm b/SL/Controller/Reclamation.pm new file mode 100644 index 000000000..6b6ee5b4e --- /dev/null +++ b/SL/Controller/Reclamation.pm @@ -0,0 +1,2532 @@ +package SL::Controller::Reclamation; + +use strict; +use parent qw(SL::Controller::Base); + +use SL::Helper::Flash qw(flash_later); +use SL::HTML::Util; +use SL::Presenter::Tag qw(select_tag hidden_tag div_tag); +use SL::Presenter::ReclamationFilter qw(filter); +use SL::Locale::String qw(t8); +use SL::SessionFile::Random; +use SL::PriceSource; +use SL::ReportGenerator; +use SL::Controller::Helper::ReportGenerator; +use SL::Webdav; +use SL::File; +use SL::MIME; +use SL::Util qw(trim); +use SL::YAML; +use SL::DB::History; +use SL::DB::Reclamation; +use SL::DB::ReclamationItem; +use SL::DB::Default; +use SL::DB::Printer; +use SL::DB::Language; +use SL::DB::RecordLink; +use SL::DB::Shipto; +use SL::DB::Translation; +use SL::DB::ValidityToken; +use SL::DB::EmailJournal; +use SL::DB::Helper::RecordLink qw(set_record_link_conversions RECORD_ID RECORD_TYPE_REF RECORD_ITEM_ID RECORD_ITEM_TYPE_REF); +use SL::DB::Helper::TypeDataProxy; +use SL::DB::Helper::Record qw(get_object_name_from_type get_class_from_type); + +use SL::Helper::CreatePDF qw(:all); +use SL::Helper::PrintOptions; +use SL::Helper::UserPreferences::PositionsScrollbar; +use SL::Helper::UserPreferences::UpdatePositions; + +use SL::Controller::Helper::GetModels; + +use SL::DB::Order; +use SL::DB::DeliveryOrder; +use SL::DB::Invoice; +use SL::Model::Record; +use SL::DB::Order::TypeData qw(:types); +use SL::DB::DeliveryOrder::TypeData qw(:types); +use SL::DB::Reclamation::TypeData qw(:types); + +use List::Util qw(first sum0); +use List::UtilsBy qw(sort_by uniq_by); +use List::MoreUtils qw(any none pairwise first_index); +use English qw(-no_match_vars); +use File::Spec; +use Cwd; +use Sort::Naturally; + +use Rose::Object::MakeMethods::Generic +( + scalar => [ qw(item_ids_to_delete is_custom_shipto_to_delete) ], + 'scalar --get_set_init' => [qw( + all_price_factors cv models p part_picker_classification_ids reclamation + search_cvpartnumber show_update_button type valid_types type_data + )], +); + + +# safety +__PACKAGE__->run_before('check_auth'); + +__PACKAGE__->run_before('recalc', + only => [qw( + save save_as_new print preview_pdf send_email + save_and_show_email_dialog + save_and_new_record + save_and_credit_note + )]); + +__PACKAGE__->run_before('get_unalterable_data', + only => [qw( + save save_as_new print preview_pdf send_email + save_and_show_email_dialog + save_and_new_record + save_and_credit_note + )]); + +# +# actions +# + +# add a new reclamation +sub action_add { + my ($self) = @_; + + $self->pre_render(); + + if (!$::form->{form_validity_token}) { + $::form->{form_validity_token} = SL::DB::ValidityToken->create(scope => SL::DB::ValidityToken::SCOPE_RECLAMATION_SAVE())->token; + } + + $self->render( + 'reclamation/form', + title => $self->type_data->text('add'), + %{$self->{template_args}}, + ); +} + +sub action_add_from_record { + my ($self) = @_; + my $from_type = $::form->{from_type}; + my $from_id = $::form->{from_id}; + + die "No 'from_type' was given." unless ($from_type); + die "No 'from_id' was given." unless ($from_id); + + my %flags = (); + if (defined($::form->{from_item_ids})) { + my %use_item = map { $_ => 1 } @{$::form->{from_item_ids}}; + $flags{item_filter} = sub { + my ($item) = @_; + return %use_item{$item->{RECORD_ITEM_ID()}}; + } + } + + my $record = SL::Model::Record->get_record($from_type, $from_id); + my $reclamation = SL::Model::Record->new_from_workflow($record, $self->type, %flags); + $self->reclamation($reclamation); + $self->reinit_after_new_reclamation(); + + if ($record->type eq SALES_RECLAMATION_TYPE()) { # check for direct delivery + # copy shipto in custom shipto (custom shipto will be copied by new_from() in case) + if ($::form->{use_shipto}) { + my $custom_shipto = $record->shipto->clone('SL::DB::Reclamation'); + $self->reclamation->custom_shipto($custom_shipto) if $custom_shipto; + } else { + # remove any custom shipto if not wanted + $self->reclamation->custom_shipto(SL::DB::Shipto->new(module => 'RC', custom_variables => [])); + } + } + + $self->action_add; +} + +sub action_add_from_email_journal { + my ($self) = @_; + die "No 'email_journal_id' was given." unless ($::form->{email_journal_id}); + + $self->action_add(); +} + +sub action_edit_with_email_journal_workflow { + my ($self) = @_; + die "No 'email_journal_id' was given." unless ($::form->{email_journal_id}); + $::form->{workflow_email_journal_id} = delete $::form->{email_journal_id}; + $::form->{workflow_email_attachment_id} = delete $::form->{email_attachment_id}; + $::form->{workflow_email_callback} = delete $::form->{callback}; + + $self->action_edit(); +} + +# edit an existing reclamation +sub action_edit { + my ($self) = @_; + die "No 'id' was given." unless $::form->{id}; + + $self->load_reclamation(); + + $self->pre_render(); + $self->render( + 'reclamation/form', + title => $self->type_data->text('edit'), + %{$self->{template_args}}, + ); +} + +# delete the reclamation +sub action_delete { + my ($self) = @_; + + + SL::Model::Record->delete($self->reclamation); + flash_later('info', $self->type_data->text('delete')); + + my @redirect_params = ( + action => 'add', + type => $self->type, + ); + + $self->redirect_to(@redirect_params); +} + +# save the reclamation +sub action_save { + my ($self) = @_; + + $self->save(); + + flash_later('info', t8('The reclamation has been saved')); + + my @redirect_params; + if ($::form->{back_to_caller}) { + @redirect_params = $::form->{callback} ? ($::form->{callback}) + : (controller => 'LoginScreen', action => 'user_login'); + } else { + @redirect_params = ( + action => 'edit', + type => $self->type, + id => $self->reclamation->id, + callback => $::form->{callback}, + ); + } + + $self->redirect_to(@redirect_params); +} + +sub action_list { + my ($self) = @_; + + $self->_setup_search_action_bar; + $self->prepare_report; + $self->report_generator_list_objects( + report => $self->{report}, + objects => $self->models->get, + options => { + action_bar_additional_submit_values => { + type => $self->type, + }, + }, + ); +} + +# save the reclamation as new document an open it for edit +sub action_save_as_new { + my ($self) = @_; + + my $reclamation = $self->reclamation; + + if (!$reclamation->id) { + $self->js->flash('error', t8('This object has not been saved yet.')); + return $self->js->render(); + } + + my $saved_reclamation = SL::DB::Reclamation->new(id => $reclamation->id)->load; + + # Create new record from current one + my $new_reclamation = SL::Model::Record->clone_for_save_as_new($saved_reclamation, $reclamation); + $self->reclamation($new_reclamation); + + if (!$::form->{form_validity_token}) { + $::form->{form_validity_token} = SL::DB::ValidityToken->create(scope => SL::DB::ValidityToken::SCOPE_RECLAMATION_SAVE())->token; + } + + # save + $self->action_save(); +} + +# print the reclamation +# +# This is called if "print" is pressed in the print dialog. +# If PDF creation was requested and succeeded, the pdf is offered for download +# via send_file (which uses ajax in this case). +sub action_print { + my ($self) = @_; + + $self->save(); + + $self->js_reset_reclamation_and_item_ids_after_save; + + my $format = $::form->{print_options}->{format}; + my $media = $::form->{print_options}->{media}; + my $formname = $::form->{print_options}->{formname}; + my $copies = $::form->{print_options}->{copies}; + my $groupitems = $::form->{print_options}->{groupitems}; + my $printer_id = $::form->{print_options}->{printer_id}; + + # only pdf and opendocument by now + if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf)) { + return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render; + } + + # only screen or printer by now + if (none { $media eq $_ } qw(screen printer)) { + return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render; + } + + # create a form for generate_attachment_filename + my $form = Form->new; + $form->{record_number} = $self->reclamation->record_number; + $form->{type} = $self->type; + $form->{format} = $format; + $form->{formname} = $formname; + $form->{language} = '_' . $self->reclamation->language->template_code if $self->reclamation->language; + my $pdf_filename = $form->generate_attachment_filename(); + + my $pdf; + my @errors = generate_pdf($self->reclamation, \$pdf, { + format => $format, + formname => $formname, + language => $self->reclamation->language, + printer_id => $printer_id, + groupitems => $groupitems, + }); + if (scalar @errors) { + return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render; + } + + if ($media eq 'screen') { # screen/download + $self->js->flash('info', t8('The PDF has been created')); + $self->send_file( + \$pdf, + type => SL::MIME->mime_type_from_ext($pdf_filename), + name => $pdf_filename, + js_no_render => 1, + ); + } elsif ($media eq 'printer') { # printer + my $printer_id = $::form->{print_options}->{printer_id}; + SL::DB::Printer->new(id => $printer_id)->load->print_document( + copies => $copies, + content => $pdf, + ); + $self->js->flash('info', t8('The PDF has been printed')); + } + + my @warnings = store_pdf_to_webdav_and_filemanagement($self->reclamation, $pdf, $pdf_filename); + if (scalar @warnings) { + $self->js->flash('warning', $_) for @warnings; + } + + $self->save_history('PRINTED'); + + $self->js + ->run('kivi.ActionBar.setEnabled', '#save_and_email_action') + ->render; +} + +sub action_preview_pdf { + my ($self) = @_; + + $self->save(); + + $self->js_reset_reclamation_and_item_ids_after_save; + + my $format = 'pdf'; + my $media = 'screen'; + my $formname = $self->type; + + # only pdf + # create a form for generate_attachment_filename + my $form = Form->new; + $form->{record_number} = $self->reclamation->record_number; + $form->{type} = $self->type; + $form->{format} = $format; + $form->{formname} = $formname; + $form->{language} = '_' . $self->reclamation->language->template_code if $self->reclamation->language; + my $pdf_filename = $form->generate_attachment_filename(); + + my $pdf; + my @errors = generate_pdf($self->reclamation, \$pdf, { + format => $format, + formname => $formname, + language => $self->reclamation->language, + }); + if (scalar @errors) { + return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render; + } + $self->save_history('PREVIEWED'); + $self->js->flash('info', t8('The PDF has been previewed')); + # screen/download + $self->send_file( + \$pdf, + type => SL::MIME->mime_type_from_ext($pdf_filename), + name => $pdf_filename, + js_no_render => 0, + ); +} + +# open the email dialog +sub action_save_and_show_email_dialog { + my ($self) = @_; + + $self->save(); + $self->js_reset_reclamation_and_item_ids_after_save; + + my $cv = $self->reclamation->customervendor + or return $self->js->flash('error', + $self->type_data->properties('is_customer') ? + t8('Cannot send E-mail without customer given') + : t8('Cannot send E-mail without vendor given') + )->render($self); + + my $form = Form->new; + $form->{record_number} = $self->reclamation->record_number; + $form->{cv_record_number} = $self->reclamation->cv_record_number; + $form->{formname} = $self->type; + $form->{type} = $self->type; + $form->{language} = '_' . $self->reclamation->language->template_code if $self->reclamation->language; + $form->{language_id} = $self->reclamation->language->id if $self->reclamation->language; + $form->{format} = 'pdf'; + $form->{cp_id} = $self->reclamation->contact->cp_id if $self->reclamation->contact; + + my $email_form; + $email_form->{to} = + ($self->reclamation->contact ? $self->reclamation->contact->cp_email : undef) + || $cv->email; + $email_form->{cc} = $cv->cc; + $email_form->{bcc} = join ', ', grep $_, $cv->bcc; + # TODO: get addresses from shipto, if any + $email_form->{subject} = $form->generate_email_subject(); + $email_form->{attachment_filename} = $form->generate_attachment_filename(); + $email_form->{message} = $form->generate_email_body(); + $email_form->{js_send_function} = 'kivi.Reclamation.send_email()'; + + my %files = $self->get_files_for_email_dialog(); + + my @employees_with_email = grep { + my $user = SL::DB::Manager::AuthUser->find_by(login => $_->login); + $user && !!trim($user->get_config_value('email')); + } @{ SL::DB::Manager::Employee->get_all_sorted(query => [ deleted => 0 ]) }; + + my $dialog_html = $self->render( + 'common/_send_email_dialog', { output => 0 }, + email_form => $email_form, + show_bcc => $::auth->assert('email_bcc', 'may fail'), + FILES => \%files, + is_customer => $self->type_data->properties('is_customer'), + ALL_EMPLOYEES => \@employees_with_email, + ALL_PARTNER_EMAIL_ADDRESSES => $cv->get_all_email_addresses(), + ); + + $self->js + ->run('kivi.Reclamation.show_email_dialog', $dialog_html) + ->reinit_widgets + ->render($self); +} + +# send email +sub action_send_email { + my ($self) = @_; + + eval { + $self->save(); + 1; + } or do { + $self->js->run('kivi.Reclamation.close_email_dialog'); + die $EVAL_ERROR; + }; + + my @redirect_params = ( + action => 'edit', + type => $self->type, + id => $self->reclamation->id, + ); + + # Set the error handler to reload the document and display errors later, + # because the document is already saved and saving can have some side effects + # such as generating a document number, project number or record links, + # which will be up to date when the document is reloaded. + # Hint: Do not use "die" here and try to catch exceptions in subroutine + # calls. You should use "$::form->error" which respects the error handler. + local $::form->{__ERROR_HANDLER} = sub { + flash_later('error', $_[0]); + $self->redirect_to(@redirect_params); + $::dispatcher->end_request; + }; + + # move $::form->{email_form} to $::form + my $email_form = delete $::form->{email_form}; + + if ($email_form->{additional_to}) { + $email_form->{to} = join ', ', grep { $_ } $email_form->{to}, @{$email_form->{additional_to}}; + delete $email_form->{additional_to}; + } + + my %field_names = (to => 'email'); + $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form }; + + # for Form::cleanup which may be called in Form::send_email + $::form->{cwd} = getcwd(); + $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath}; + + $::form->{$_} = $::form->{print_options}->{$_} for keys %{$::form->{print_options}}; + $::form->{media} = 'email'; + + $::form->{attachment_policy} //= ''; + + # Is an old file version available? + my $attfile; + if ($::form->{attachment_policy} eq 'old_file') { + $attfile = SL::File->get_all( + object_id => $self->reclamaiton->id, + object_type => $self->type, + print_variant => $::form->{formname}, + ); + } + + if ( $::form->{attachment_policy} ne 'no_file' + && !($::form->{attachment_policy} eq 'old_file' && $attfile) + ) { + my $pdf; + my @errors = generate_pdf( + $self->reclamation, \$pdf, { + media => $::form->{media}, + format => $::form->{print_options}->{format}, + formname => $::form->{print_options}->{formname}, + language => $self->reclamation->language, + printer_id => $::form->{print_options}->{printer_id}, + groupitems => $::form->{print_options}->{groupitems}, + }); + if (scalar @errors) { + $::form->error(t8('Generating the document failed: #1', $errors[0])); + } + + my @warnings = store_pdf_to_webdav_and_filemanagement( + $self->reclamation, $pdf, $::form->{attachment_filename} + ); + if (scalar @warnings) { + flash_later('warning', $_) for @warnings; + } + + my $sfile = SL::SessionFile::Random->new(mode => "w"); + $sfile->fh->print($pdf); + $sfile->fh->close; + + $::form->{tmpfile} = $sfile->file_name; + $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be + # called in Form::send_email + } + + $::form->{id} = $self->reclamation->id; # this is used in SL::Mailer to + # create a linked record to the mail + $::form->send_email(\%::myconfig, 'pdf'); + + flash_later('info', t8('The email has been sent.')); + $self->save_history('MAILED'); + + # internal notes unless no email journal + unless ($::instance_conf->get_email_journal) { + my $intnotes = $self->reclamation->intnotes; + $intnotes .= "\n\n" if $self->reclamation->intnotes; + $intnotes .= t8('[email]') . "\n"; + $intnotes .= t8('Date') . ": " . $::locale->format_date_object( + DateTime->now_local, + precision => 'seconds') . "\n"; + $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n"; + $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc}; + $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc}; + $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n"; + $intnotes .= t8('Message') . ": " . SL::HTML::Util->strip($::form->{message}); + + $self->reclamation->update_attributes(intnotes => $intnotes); + } + + $self->redirect_to(@redirect_params); +} + +sub action_save_and_new_record { + my ($self) = @_; + my $to_type = $::form->{to_type}; + my $to_controller = get_object_name_from_type($to_type); + + $self->save(); + flash_later('info', t8('The reclamation has been saved')); + + my %additional_params = (); + if ($::form->{only_selected_item_positions}) { # ids can be unset before save + my $item_positions = $::form->{selected_item_positions} || []; + my @from_item_ids = map { $self->reclamation->items_sorted->[$_]->id } @$item_positions; + $additional_params{from_item_ids} = \@from_item_ids; + } + + $self->redirect_to( + controller => $to_controller, + action => 'add_from_record', + type => $to_type, + from_id => $self->reclamation->id, + from_type => $self->reclamation->type, + email_journal_id => $::form->{workflow_email_journal_id}, + email_attachment_id => $::form->{workflow_email_attachment_id}, + callback => $::form->{workflow_email_callback}, + %additional_params, + ); +} + +# save the reclamation and redirect to the frontend subroutine for a new +# credit_note +sub action_save_and_credit_note { + my ($self) = @_; + + # always save + $self->save(); + + if (!$self->reclamation->is_sales) { + $self->js->flash('error', t8("Can't convert Purchase Reclamation to Credit Note")); + return $self->js->render(); + } + + flash_later('info', t8('The reclamation has been saved')); + $self->redirect_to( + controller => 'is.pl', + action => 'credit_note_from_reclamation', + from_id => $self->reclamation->id, + email_journal_id => $::form->{workflow_email_journal_id}, + email_attachment_id => $::form->{workflow_email_attachment_id}, + callback => $::form->{workflow_email_callback}, + ); +} + +# set form elements in respect to a changed customer or vendor +# +# This action is called on an change of the customer/vendor picker. +sub action_customer_vendor_changed { + my ($self) = @_; + + $self->reclamation( + SL::Model::Record->update_after_customer_vendor_change($self->reclamation)); + + $self->recalc(); + + if ( $self->reclamation->customervendor->contacts + && scalar @{ $self->reclamation->customervendor->contacts } > 0) { + $self->js->show('#cp_row'); + } else { + $self->js->hide('#cp_row'); + } + + if ($self->reclamation->customervendor->shipto + && scalar @{ $self->reclamation->customervendor->shipto } > 0) { + $self->js->show('#shipto_selection'); + } else { + $self->js->hide('#shipto_selection'); + } + + $self->js->val( '#reclamation_salesman_id', $self->reclamation->salesman_id) if $self->reclamation->is_sales; + + $self->js + ->replaceWith('#reclamation_cp_id', $self->build_contact_select) + ->replaceWith('#reclamation_shipto_id', $self->build_shipto_select) + ->replaceWith('#shipto_inputs ', $self->build_shipto_inputs) + ->replaceWith('#business_info_row', $self->build_business_info_row) + ->val( '#reclamation_taxzone_id', $self->reclamation->taxzone_id) + ->val( '#reclamation_taxincluded', $self->reclamation->taxincluded) + ->val( '#reclamation_currency_id', $self->reclamation->currency_id) + ->val( '#reclamation_payment_id', $self->reclamation->payment_id) + ->val( '#reclamation_delivery_term_id', $self->reclamation->delivery_term_id) + ->val( '#reclamation_intnotes', $self->reclamation->intnotes) + ->val( '#reclamation_language_id', $self->reclamation->customervendor->language_id) + ->focus( '#reclamation_' . $self->cv . '_id') + ->run('kivi.Reclamation.update_exchangerate'); + + $self->js_redisplay_amounts_and_taxes; + $self->js_redisplay_cvpartnumbers; + $self->js->render(); +} + +# called if a unit in an existing item row is changed +sub action_unit_changed { + my ($self) = @_; + + my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{reclamation_item_ids} }; + my $item = $self->reclamation->items_sorted->[$idx]; + + my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load; + $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj)); + + $self->recalc(); + + $self->js + ->run('kivi.Reclamation.update_sellprice', $::form->{item_id}, $item->sellprice_as_number); + $self->js_redisplay_line_values; + $self->js_redisplay_amounts_and_taxes; + $self->js->render(); +} + +# add an item row for a new item entered in the input row +sub action_add_item { + my ($self) = @_; + + delete $::form->{add_item}->{create_part_type}; + + my $form_attr = $::form->{add_item}; + + unless ($form_attr->{parts_id}) { + $self->js->flash('error', t8("No part was selected.")); + return $self->js->render(); + } + + + my $item = new_item($self->reclamation, $form_attr); + + $self->reclamation->add_items($item); + + $self->recalc(); + + $self->get_item_cvpartnumber($item); + + my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000); + my $row_as_html = $self->p->render('reclamation/tabs/basic_data/_row', + ITEM => $item, + ID => $item_id, + SELF => $self, + ); + + if ($::form->{insert_before_item_id}) { + $self->js + ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html); + } else { + $self->js + ->append('#row_table_id', $row_as_html); + } + + if ( $item->part->is_assortment ) { + $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number}; + foreach my $assortment_item ( @{$item->part->assortment_items} ) { + my $attr = { parts_id => $assortment_item->parts_id, + qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit} + unit => $assortment_item->unit, + description => $assortment_item->part->description, + }; + my $item = new_item($self->reclamation, $attr); + + # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount + $item->discount(1) unless $assortment_item->charge; + + $self->reclamation->add_items( $item ); + $self->recalc(); + $self->get_item_cvpartnumber($item); + my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000); + my $row_as_html = $self->p->render('reclamation/tabs/basic_data/_row', + ITEM => $item, + ID => $item_id, + SELF => $self, + ); + if ($::form->{insert_before_item_id}) { + $self->js + ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html); + } else { + $self->js + ->append('#row_table_id', $row_as_html); + } + }; + }; + + $self->js + ->val('.add_item_input', '') + ->run('kivi.Reclamation.init_row_handlers') + ->run('kivi.Reclamation.renumber_positions') + ->focus('#add_item_parts_id_name'); + + $self->js->run('kivi.Reclamation.row_table_scroll_down') if !$::form->{insert_before_item_id}; + + $self->js_redisplay_amounts_and_taxes; + $self->js->render(); +} + +# add item rows for multiple items at once +sub action_add_multi_items { + my ($self) = @_; + + my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_items} }; + unless (scalar(@form_attr)) { + $self->js->flash('error', t8("No part was selected.")); + return $self->js->render(); + } + + my @items; + foreach my $attr (@form_attr) { + my $item = new_item($self->reclamation, $attr); + push @items, $item; + if ( $item->part->is_assortment ) { + foreach my $assortment_item ( @{$item->part->assortment_items} ) { + my $attr = { parts_id => $assortment_item->parts_id, + qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit} + unit => $assortment_item->unit, + description => $assortment_item->part->description, + }; + my $item = new_item($self->reclamation, $attr); + + # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount + $item->discount(1) unless $assortment_item->charge; + push @items, $item; + } + } + } + $self->reclamation->add_items(@items); + + $self->recalc(); + + foreach my $item (@items) { + $self->get_item_cvpartnumber($item); + my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000); + my $row_as_html = $self->p->render('reclamation/tabs/basic_data/_row', + ITEM => $item, + ID => $item_id, + SELF => $self, + ); + + if ($::form->{insert_before_item_id}) { + $self->js + ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html); + } else { + $self->js + ->append('#row_table_id', $row_as_html); + } + } + + $self->js + ->run('kivi.Part.close_picker_dialogs') + ->run('kivi.Reclamation.init_row_handlers') + ->run('kivi.Reclamation.renumber_positions') + ->focus('#add_item_parts_id_name'); + + $self->js->run('kivi.Reclamation.row_table_scroll_down') if !$::form->{insert_before_item_id}; + + $self->js_redisplay_amounts_and_taxes; + $self->js->render(); +} + +# recalculate all linetotals, amounts and taxes and redisplay them +sub action_recalc_amounts_and_taxes { + my ($self) = @_; + + $self->recalc(); + + $self->js_redisplay_line_values; + $self->js_redisplay_amounts_and_taxes; + $self->js->render(); +} + +sub action_update_exchangerate { + my ($self) = @_; + + my $data = { + is_standard => $self->reclamation->currency_id == $::instance_conf->get_currency_id, + currency_name => $self->reclamation->currency->name, + exchangerate => $self->reclamation->daily_exchangerate_as_null_number, + }; + + $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 }); +} + +# redisplay item rows if they are sorted by an attribute +sub action_reorder_items { + my ($self) = @_; + + my %sort_keys = ( + partnumber => sub { $_[0]->part->partnumber }, + description => sub { $_[0]->description }, + reason => sub { $_[0]->reason eq undef ? "" : $_[0]->reason->name }, + reason_description_ext => sub { $_[0]->reason_description_ext }, + reason_description_int => sub { $_[0]->reason_description_int }, + qty => sub { $_[0]->qty }, + sellprice => sub { $_[0]->sellprice }, + discount => sub { $_[0]->discount }, + cvpartnumber => sub { $_[0]->{cvpartnumber} }, + ); + + $self->get_item_cvpartnumber($_) for @{$self->reclamation->items_sorted}; + + my $method = $sort_keys{$::form->{order_by}}; + my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->reclamation->items_sorted }; + if ($::form->{sort_dir}) { + if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){ + @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort; + } else { + @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort; + } + } else { + if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){ + @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort; + } else { + @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort; + } + } + $self->js + ->run('kivi.Reclamation.redisplay_items', \@to_sort) + ->render; +} + +# show the popup to choose a price/discount source +sub action_price_popup { + my ($self) = @_; + + my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{reclamation_item_ids} }; + my $item = $self->reclamation->items_sorted->[$idx]; + if ($item->is_linked_to_record) { + $self->js->flash('error', t8("Can't change price of a linked item")); + return $self->js->render(); + } + + $self->render_price_dialog($item); +} + +# save the reclamation in a session variable and redirect to the part controller +sub action_create_part { + my ($self) = @_; + + my $previousform = $::auth->save_form_in_session(non_scalars => 1); + + my $callback = $self->url_for( + action => 'return_from_create_part', + type => $self->type, # type is needed for check_auth on return + previousform => $previousform, + ); + + flash_later('info', t8('You are adding a new part while you are editing another document. You will be redirected to your document when saving the new part or aborting this form.')); + + my @redirect_params = ( + controller => 'Part', + action => 'add', + part_type => $::form->{add_item}->{create_part_type}, + callback => $callback, + show_abort => 1, + ); + + $self->redirect_to(@redirect_params); +} + +sub action_return_from_create_part { + my ($self) = @_; + + $self->{created_part} = SL::DB::Part->new( + id => delete $::form->{new_parts_id} + )->load if $::form->{new_parts_id}; + + $::auth->restore_form_from_session(delete $::form->{previousform}); + $self->reclamation($self->init_reclamation); + $self->reinit_after_new_reclamation(); + + if ($self->reclamation->id) { + $self->pre_render(); + $self->render( + 'reclamation/form', + title => $self->type_data->text('edit'), + %{$self->{template_args}}, + ); + } else { + $self->action_add; + } +} + +# load the second row for one or more items +# +# This action gets the html code for all items second rows by rendering a template for +# the second row and sets the html code via client js. +sub action_load_second_rows { + my ($self) = @_; + + foreach my $item_id (@{ $::form->{reclamation_item_ids} }) { + my $idx = first_index { $_ eq $item_id } @{ $::form->{reclamation_item_ids} }; + my $item = $self->reclamation->items_sorted->[$idx]; + + $self->js_load_second_row($item, $item_id, 0); + } + + $self->js->run('kivi.Reclamation.init_row_handlers') if $self->reclamation->is_sales; # for lastcosts change-callback + + $self->js->render(); +} + +# update description, notes and sellprice from master data +sub action_update_row_from_master_data { + my ($self) = @_; + + foreach my $item_id (@{ $::form->{item_ids} }) { + my $idx = first_index { $_ eq $item_id } @{ $::form->{reclamation_item_ids} }; + my $item = $self->reclamation->items_sorted->[$idx]; + + if ($item->is_linked_to_record) { + $self->js->flash_later('error', t8("Can't change data of a linked item. Part: " . $item->part->partnumber)); + next; + } + + my $texts = get_part_texts($item->part, $self->reclamation->language_id); + + $item->description($texts->{description}); + $item->longdescription($texts->{longdescription}); + + my ($price_src, undef) = SL::Model::Record->get_best_price_and_discount_source($self->reclamation, $item, ignore_given => 1); + $item->sellprice($price_src->price); + $item->active_price_source($price_src); + + $self->js + ->run('kivi.Reclamation.update_sellprice', $item_id, $item->sellprice_as_number) + ->html('.row_entry:has(#item_' . $item_id + . ') [name = "partnumber"] a', $item->part->partnumber) + ->val ('.row_entry:has(#item_' . $item_id + . ') [name = "reclamation.reclamation_items[].description"]', + $item->description) + ->val ('.row_entry:has(#item_' . $item_id + . ') [name = "reclamation.reclamation_items[].longdescription"]', + $item->longdescription); + + if ($self->search_cvpartnumber) { + $self->get_item_cvpartnumber($item); + $self->js->html('.row_entry:has(#item_' . $item_id + . ') [name = "cvpartnumber"]', $item->{cvpartnumber}); + } + } + + $self->recalc(); + $self->js_redisplay_line_values; + $self->js_redisplay_amounts_and_taxes; + + $self->js->render(); +} + +sub js_load_second_row { + my ($self, $item, $item_id, $do_parse) = @_; + + if ($do_parse) { + # Parse values from form (they are formated while rendering (template)). + # Workaround to pre-parse number-cvars (parse_custom_variable_values does + # not parse number values). This parsing is not necessary at all, if we + # assure that the second row/cvars are only loaded once. + foreach my $var (@{ $item->cvars_by_config }) { + if ($var->config->type eq 'number' && exists($var->{__unparsed_value})) { + $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})); + } + } + $item->parse_custom_variable_values; + } + + my $row_as_html = $self->p->render('reclamation/tabs/basic_data/_second_row', ITEM => $item, TYPE => $self->type); + + $self->js + ->html('#second_row_' . $item_id, $row_as_html) + ->data('#second_row_' . $item_id, 'loaded', 1); +} + +sub js_redisplay_line_values { + my ($self) = @_; + + my @data = map {[ + $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0), + ]} @{ $self->reclamation->items_sorted }; + + $self->js + ->run('kivi.Reclamation.redisplay_line_values', $self->reclamation->is_sales, \@data); +} + +sub js_redisplay_amounts_and_taxes { + my ($self) = @_; + + if (scalar @{ $self->reclamation->taxes }) { + $self->js->show('#taxincluded_row_id'); + } else { + $self->js->hide('#taxincluded_row_id'); + } + + if ($self->reclamation->taxincluded) { + $self->js->hide('#subtotal_row_id'); + } else { + $self->js->show('#subtotal_row_id'); + } + + $self->js + ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->reclamation->netamount, -2)) + ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->reclamation->amount, -2)) + ->remove('.tax_row') + ->insertBefore($self->build_tax_rows, '#amount_row_id'); +} + +sub js_redisplay_cvpartnumbers { + my ($self) = @_; + + $self->get_item_cvpartnumber($_) for @{$self->reclamation->items_sorted}; + + my @data = map {[$_->{cvpartnumber}]} @{ $self->reclamation->items_sorted }; + + $self->js + ->run('kivi.Reclamation.redisplay_cvpartnumbers', \@data); +} +sub js_reset_reclamation_and_item_ids_after_save { + my ($self) = @_; + + $self->js + ->val('#id', $self->reclamation->id) + ->val('#converted_from_record_type_ref', '') + ->val('#converted_from_record_id', '') + ->val('#reclamation_record_number', $self->reclamation->record_number); + + my $idx = 0; + foreach my $form_item_id (@{ $::form->{reclamation_item_ids} }) { + next if !$self->reclamation->items_sorted->[$idx]->id; + next if $form_item_id !~ m{^new}; + $self->js + ->val ('[name="reclamation_item_ids[+]"][value="' . $form_item_id . '"]', + $self->reclamation->items_sorted->[$idx]->id) + ->val ('#item_' . $form_item_id, + $self->reclamation->items_sorted->[$idx]->id) + ->attr('#item_' . $form_item_id, "id", + 'item_' . $self->reclamation->items_sorted->[$idx]->id); + } continue { + $idx++; + } + $self->js->val('[name="converted_from_record_item_type_refs[+]"]', ''); + $self->js->val('[name="converted_from_record_item_ids[+]"]', ''); +} + +# +# helpers +# + +sub init_valid_types { + $_[0]->type_data->valid_types; +} + +sub init_type { + my ($self) = @_; + + my $type = $self->reclamation->record_type; + if (none { $type eq $_ } @{$self->valid_types}) { + die "Not a valid type for reclamation"; + } + + $self->type($type); +} + +sub init_cv { + my ($self) = @_; + + $self->type_data->properties('customervendor'); +} + +sub init_search_cvpartnumber { + my ($self) = @_; + + my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new(); + my $search_cvpartnumber; + if ($self->type_data->properties('is_customer')) { + $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() + } else { + $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel(); + } + + return $search_cvpartnumber; +} + +sub init_show_update_button { + my ($self) = @_; + + !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button(); +} + +sub init_models { + my ($self) = @_; + + SL::Controller::Helper::GetModels->new( + controller => $self, + sorted => { + _default => { + by => 'record_number', + dir => 0, + }, + id => t8('ID'), + record_number => t8('Reclamation Number'), + employee => t8('Employee'), + salesman => t8('Salesman'), + customer => t8('Customer'), + vendor => t8('Vendor'), + contact => t8('Contact'), + language => t8('Language'), + department => t8('Department'), + globalproject => t8('Project Number'), + cv_record_number => ($self->type_data->properties('is_customer') ? t8('Customer Record Number') : t8('Vendor Record Number')), + transaction_description => t8('Description'), + notes => t8('Notes'), + intnotes => t8('Internal Notes'), + shippingpoint => t8('Shipping Point'), + shipvia => t8('Ship via'), + shipto_id => t8('Shipping Address'), + amount => t8('Total'), + netamount => t8('Subtotal'), + delivery_term => t8('Delivery Terms'), + payment => t8('Payment Terms'), + currency => t8('Currency'), + exchangerate => t8('Exchangerate'), + taxincluded => t8('Tax Included'), + taxzone => t8('Tax zone'), + tax_point => t8('Tax point'), + reqdate => t8('Deadline'), + transdate => t8('Booking Date'), + itime => t8('Creation Time'), + mtime => t8('Last modification Time'), + delivered => t8('Delivered'), + closed => t8('Closed'), + }, + query => [ + SL::DB::Manager::Reclamation->type_filter($self->type), + (salesman_id => SL::DB::Manager::Employee->current->id) x ($self->reclamation->is_sales && !$::auth->assert('sales_all_edit', 1)), + (employee_id => SL::DB::Manager::Employee->current->id) x ($self->reclamation->is_sales && !$::auth->assert('sales_all_edit', 1)), + (employee_id => SL::DB::Manager::Employee->current->id) x (!$self->reclamation->is_sales && !$::auth->assert('purchase_all_edit', 1)), + ], + + with_objects => [ + 'customer', 'vendor', 'employee', 'salesman', + 'contact', 'language', 'department', 'globalproject', + 'delivery_term', 'payment', 'currency', 'taxzone', + ], + ); +} + +sub init_p { + SL::Presenter->get; +} + +sub init_reclamation { + $_[0]->make_reclamation; +} + +sub init_all_price_factors { + SL::DB::Manager::PriceFactor->get_all; +} + +sub init_part_picker_classification_ids { + my ($self) = @_; + + return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => $self->type_data->part_classification_query()) } ]; +} + +sub check_auth { + my ($self) = @_; + $::auth->assert($self->type_data->rights('edit')); +} + +# build the selection box for contacts +# +# Needed, if customer/vendor changed. +sub build_contact_select { + my ($self) = @_; + + select_tag('reclamation.contact_id', [ $self->reclamation->customervendor->contacts ], + value_key => 'cp_id', + title_key => 'full_name_dep', + default => $self->reclamation->contact_id, + with_empty => 1, + style => 'width: 300px', + ); +} + +# build the selection box for shiptos +# +# Needed, if customer/vendor changed. +sub build_shipto_select { + my ($self) = @_; + + select_tag('reclamation.shipto_id', + [ {displayable_id => t8("No/individual shipping address"), + shipto_id => '', + }, + $self->reclamation->customervendor->shipto + ], + value_key => 'shipto_id', + title_key => 'displayable_id', + default => $self->reclamation->shipto_id, + with_empty => 0, + style => 'width: 300px', + ); +} + +# build the inputs for the cusom shipto dialog +# +# Needed, if customer/vendor changed. +sub build_shipto_inputs { + my ($self) = @_; + + my $content = $self->p->render('common/_ship_to_dialog', + cv_obj => $self->reclamation->customervendor, + cs_obj => $self->reclamation->custom_shipto, + cvars => $self->reclamation->custom_shipto->cvars_by_config, + id_selector => '#reclamation_shipto_id'); + + div_tag($content, id => 'shipto_inputs'); +} + +# render the info line for business +# +# Needed, if customer/vendor changed. +sub build_business_info_row +{ + $_[0]->p->render('reclamation/tabs/basic_data/_business_info_row', SELF => $_[0]); +} + +# build the rows for displaying taxes +# +# Called if amounts where recalculated and redisplayed. +sub build_tax_rows { + my ($self) = @_; + + my $rows_as_html; + foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->reclamation->taxes }) { + $rows_as_html .= $self->p->render( + 'reclamation/tabs/basic_data/_tax_row', + SELF => $self, + TAX => $tax, + TAXINCLUDED => $self->reclamation->taxincluded, + ); + } + return $rows_as_html; +} + +sub render_price_dialog { + my ($self, $record_item) = @_; + + my $price_source = SL::PriceSource->new( + record_item => $record_item, + record => $self->reclamation, + ); + + $self->js + ->run( + 'kivi.io.price_chooser_dialog', + t8('Available Prices'), + $self->render( + 'reclamation/tabs/basic_data/_price_sources_dialog', + { output => 0 }, + price_source => $price_source, + ), + ) + ->reinit_widgets; + +# if (@errors) { +# $self->js->text('#dialog_flash_error_content', join ' ', @errors); +# $self->js->show('#dialog_flash_error'); +# } + + $self->js->render; +} + +# load or create a new reclamation object +# +# And assign changes from the form to this object. +# If the reclamation is loaded from db, check if items are deleted in the form, +# remove them form the object and collect them for removing from db on saving. +# Then create/update items from form (via make_item) and add them. +sub make_reclamation { + my ($self) = @_; + + # add_items adds items to an reclamation with no items for saving, but they + # cannot be retrieved via items until the reclamation is saved. Adding empty + # items to new reclamation here solves this problem. + my $reclamation; + if ($::form->{id}) { + $reclamation = SL::DB::Reclamation->new(id => $::form->{id})->load(); + } else { + $reclamation = SL::DB::Reclamation->new( + record_type => $::form->{type}, + reclamation_items => [], + currency_id => $::instance_conf->get_currency_id(), + ); + $reclamation = SL::Model::Record->update_after_new($reclamation) + } + + my $cv_id_method = $reclamation->type_data->properties('customervendor'). '_id'; + if (!$::form->{id} && $::form->{$cv_id_method}) { + $reclamation->$cv_id_method($::form->{$cv_id_method}); + $reclamation = SL::Model::Record->update_after_customer_vendor_change($reclamation); + } + + # don't assign hashes as objects + my $form_reclamation_items = delete $::form->{reclamation}->{reclamation_items}; + + $reclamation->assign_attributes(%{$::form->{reclamation}}); + + # restore form values + $::form->{reclamation}->{reclamation_items} = $form_reclamation_items; + + $self->setup_custom_shipto_from_form($reclamation, $::form); + + # remove deleted items + $self->item_ids_to_delete([]); + foreach my $idx (reverse 0..$#{$reclamation->reclamation_items}) { + my $item = $reclamation->reclamation_items->[$idx]; + if (none { $item->id == $_->{id} } @{$form_reclamation_items}) { + splice @{$reclamation->reclamation_items}, $idx, 1; + push @{$self->item_ids_to_delete}, $item->id; + } + } + + my @items; + my $pos = 1; + foreach my $form_attr (@{$form_reclamation_items}) { + my $item = make_item($reclamation, $form_attr); + $item->position($pos); + push @items, $item; + $pos++; + } + $reclamation->add_items(grep {!$_->id} @items); + + return $reclamation; +} + +# create or update items from form +# +# Make item objects from form values. For items already existing read from db. +# Create a new item else. And assign attributes. +sub make_item { + my ($record, $attr) = @_; + + my $item; + $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id}; + + my $is_new = !$item; + + # add_custom_variables adds cvars to an reclamation_item with no cvars for + # saving, but they cannot be retrieved via custom_variables until the + # reclamation/reclamation_item is saved. Adding empty custom_variables to new + # reclamationitem here solves this problem. + $item ||= SL::DB::ReclamationItem->new(custom_variables => []); + + $item->assign_attributes(%$attr); + + if ($is_new) { + my $texts = get_part_texts($item->part, $record->language_id); + $item->longdescription($texts->{longdescription}) if !defined $attr->{longdescription}; + $item->project_id($record->globalproject_id) if !defined $attr->{project_id}; + $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number}; + } + + return $item; +} + +sub load_reclamation { + my ($self) = @_; + + return if !$::form->{id}; + + $self->reclamation(SL::DB::Reclamation->new(id => $::form->{id})->load); + + $self->reinit_after_new_reclamation(); + + return $self->reclamation; +} + +# create a new item +# +# This is used to add one item +sub new_item { + my ($record, $attr) = @_; + + my $item = SL::DB::ReclamationItem->new; + + # Remove attributes where the user left or set the inputs empty. + # So these attributes will be undefined and we can distinguish them + # from zero later on. + for (qw(qty_as_number sellprice_as_number discount_as_percent)) { + delete $attr->{$_} if $attr->{$_} eq ''; + } + + $item->assign_attributes(%$attr); + + my $part = SL::DB::Part->new(id => $attr->{parts_id})->load; + $item->qty(1.0) if !$item->qty; + $item->unit($part->unit) if !$item->unit; + + my ($price_src, $discount_src) = SL::Model::Record->get_best_price_and_discount_source($record, $item, ignore_given => 0); + + my %new_attr; + $new_attr{part} = $part; + $new_attr{description} = $part->description if ! $item->description; + $new_attr{qty} = 1.0 if ! $item->qty; + $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id; + $new_attr{sellprice} = $price_src->price; + $new_attr{discount} = $discount_src->discount; + $new_attr{active_price_source} = $price_src; + $new_attr{active_discount_source} = $discount_src; + $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription}; + $new_attr{project_id} = $record->globalproject_id; + $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0; + + # add_custom_variables adds cvars to an reclamationitem with no cvars for saving, but + # they cannot be retrieved via custom_variables until the reclamation/reclamationitem is + # saved. Adding empty custom_variables to new reclamationitem here solves this problem. + $new_attr{custom_variables} = []; + + my $texts = get_part_texts($part, $record->language_id, + description => $new_attr{description}, + longdescription => $new_attr{longdescription}, + ); + + $item->assign_attributes(%new_attr, %{ $texts }); + + $item->reclamation($record); + return $item; +} + +# setup custom shipto from form +# +# The dialog returns form variables starting with 'shipto' and cvars starting +# with 'shiptocvar_'. +# Mark it to be deleted if a shipto from master data is selected +# (i.e. reclamation has a shipto). +# Else, update or create a new custom shipto. If the fields are empty, it +# will not be saved on save. +sub setup_custom_shipto_from_form { + my ($self, $reclamation, $form) = @_; + + if ($reclamation->shipto) { + $self->is_custom_shipto_to_delete(1); + } else { + my $custom_shipto = $reclamation->custom_shipto + || $reclamation->custom_shipto( + SL::DB::Shipto->new(module => 'RC', custom_variables => []) + ); + + my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form}; + my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form}; + + $custom_shipto->assign_attributes(%$shipto_attrs); + $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars; + } +} + +# recalculate prices and taxes +# +# Using the PriceTaxCalculator. Store linetotals in the item objects. +sub recalc { + my ($self) = @_; + + my %pat = $self->reclamation->calculate_prices_and_taxes(); + + pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->reclamation->items_sorted}, @{$pat{items}}; +} + +# get data for saving, printing, ..., that is not changed in the form +# +# Only cvars for now. +sub get_unalterable_data { + my ($self) = @_; + + foreach my $item (@{ $self->reclamation->items }) { + # autovivify all cvars that are not in the form (cvars_by_config can do it). + # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values). + foreach my $var (@{ $item->cvars_by_config }) { + if ($var->config->type eq 'number' && exists($var->{__unparsed_value})) { + $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})); + } + } + $item->parse_custom_variable_values; + } +} + +# save the reclamation +# +# And delete items that are deleted in the form. +sub save { + my ($self) = @_; + + set_record_link_conversions($self->reclamation, + delete $::form->{RECORD_TYPE_REF()} + => delete $::form->{RECORD_ID()}, + delete $::form->{RECORD_ITEM_TYPE_REF()} + => delete $::form->{RECORD_ITEM_ID()}, + ); + + my $items_to_delete = scalar @{ $self->item_ids_to_delete || [] } + ? SL::DB::Manager::ReclamationItem->get_all(where => [id => $self->item_ids_to_delete]) + : undef; + + SL::Model::Record->save($self->reclamation, + with_validity_token => { scope => SL::DB::ValidityToken::SCOPE_RECLAMATION_SAVE(), token => $::form->{form_validity_token} }, + delete_custom_shipto => $self->reclamation->custom_shipto && ($self->is_custom_shipto_to_delete || $self->reclamation->custom_shipto->is_empty), + items_to_delete => $items_to_delete, + ); + + if ($::form->{email_journal_id}) { + my $email_journal = SL::DB::EmailJournal->new( + id => delete $::form->{email_journal_id} + )->load; + $email_journal->link_to_record_with_attachment( + $self->reclamation, + delete $::form->{email_attachment_id} + ); + } + + delete $::form->{form_validity_token}; +} + +sub reinit_after_new_reclamation { + my ($self) = @_; + + # change form type + $::form->{type} = $self->reclamation->type; + $self->type($self->init_type); + $self->type_data($self->init_type_data); + $self->cv($self->init_cv); + $self->check_auth; + + $self->setup_custom_shipto_from_form($self->reclamation, $::form); + + foreach my $item (@{$self->reclamation->items_sorted}) { + # set item ids to new fake id, to identify them as new items + $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000); + + # trigger rendering values for second row as hidden, because they + # are loaded only on demand. So we need to keep the values from the + # source. + $item->{render_second_row} = 1; + } + + $self->get_unalterable_data(); + $self->recalc(); +} + +sub pre_render { + my ($self) = @_; + + $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted(); + $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted(); + $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted(); + $self->{all_languages} = SL::DB::Manager::Language->get_all_sorted(); + $self->{all_employees} = SL::DB::Manager::Employee->get_all( + where => [ or => [ + id => $self->reclamation->employee_id, + deleted => 0 ] ], + sort_by => 'name'); + $self->{all_salesmen} = SL::DB::Manager::Employee->get_all( + where => [ or => [ + id => $self->reclamation->salesman_id, + deleted => 0 ] ], + sort_by => 'name'); + $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted( + where => [ or => [ + id => $self->reclamation->payment_id, + obsolete => 0 ] ]); + $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted(); + $self->{current_employee_id} = SL::DB::Manager::Employee->current->id; + + $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height(); + + my $print_form = Form->new(''); + $print_form->{type} = $self->type; + $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted; + $self->{print_options} = SL::Helper::PrintOptions->get_print_options( + form => $print_form, + options => {dialog_name_prefix => 'print_options.', + show_headers => 1, + no_queue => 1, + no_postscript => 1, + no_opendocument => 0, + no_html => 1}, + ); + foreach my $item (@{$self->reclamation->reclamation_items}) { + my $price_source = SL::PriceSource->new(record_item => $item, record => $self->reclamation); + $item->active_price_source( $price_source->price_from_source( $item->active_price_source )); + $item->active_discount_source($price_source->discount_from_source($item->active_discount_source)); + } + + if ($self->reclamation->record_number && $::instance_conf->get_webdav) { + my $webdav = SL::Webdav->new( + type => $self->type, + number => $self->reclamation->record_number, + ); + my @all_objects = $webdav->get_all_objects; + @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename, + type => t8('File'), + link => File::Spec->catfile($_->full_filedescriptor), + } } @all_objects; + } + + $self->get_item_cvpartnumber($_) for @{$self->reclamation->items_sorted}; + + $::request->{layout}->use_javascript("${_}.js") for + qw(kivi.SalesPurchase kivi.Reclamation kivi.File + calculate_qty kivi.Validator follow_up + show_history + ); + $self->_setup_edit_action_bar; +} + +sub prepare_report { + my ($self) = @_; + + my $report = SL::ReportGenerator->new(\%::myconfig, $::form); + $report->{title} = t8('Sales Reclamations'); + if ($self->type eq PURCHASE_RECLAMATION_TYPE()){ + $report->{title} = t8('Purchase Reclamations'); + } + + $self->models->disable_plugin('paginated') if $report->{options}{output_format} =~ /^(pdf|csv)$/i; + $self->models->add_additional_url_params(type => $self->type); + $self->models->finalize; # for filter laundering + + my $callback = $self->models->get_callback; + + $self->{report} = $report; + + # TODO: shipto_id is not linked to custom_shipto + my @columns_order = qw( + id + record_number + employee + salesman + customer + vendor + contact + language + department + globalproject + cv_record_number + transaction_description + notes + intnotes + shippingpoint + shipvia + amount + netamount + delivery_term + payment + currency + exchangerate + taxincluded + taxzone + tax_point + reqdate + transdate + itime + mtime + delivered + closed + ); + + my @default_columns = qw( + record_number + employee + department + globalproject + cv_record_number + transaction_description + amount + reqdate + transdate + itime + mtime + delivered + closed + ); + + my %column_defs = ( + id => { + obj_link => sub {$self->url_for(action => 'edit', id => $_[0]->id, type => $self->type, callback => $callback)}, + sub => sub { $_[0]->id }, + }, + record_number => { + obj_link => sub {$self->url_for(action => 'edit', id => $_[0]->id, type => $self->type, callback => $callback)}, + sub => sub { $_[0]->record_number }, + }, + employee => { + sub => sub { $_[0]->employee ? $_[0]->employee->name : '' }, + }, + salesman => { + sub => sub { $_[0]->salesman ? $_[0]->salesman->name : '' }, + }, + language => { + sub => sub { $_[0]->language ? $_[0]->language->article_code : '' }, + }, + department => { + sub => sub { $_[0]->department ? $_[0]->department->description : '' }, + }, + globalproject => { + obj_link => sub { $_[0]->globalproject_id ? + $self->url_for( + controller => "controller.pl", + action => 'Project/edit', + id => $_[0]->globalproject_id, + callback => $callback + ) : '' }, + sub => sub { !$_[0]->globalproject ? '' : $_[0]->globalproject->projectnumber }, + }, + cv_record_number => { + sub => sub { $_[0]->cv_record_number }, + }, + transaction_description => { + sub => sub { $_[0]->transaction_description }, + }, + notes => { + sub => sub { $_[0]->notes }, + }, + intnotes => { + sub => sub { $_[0]->intnotes }, + }, + shippingpoint => { + sub => sub { $_[0]->shippingpoint }, + }, + shipvia => { + sub => sub { $_[0]->shipvia }, + }, + # TODO: custom ship to is not safed in reclamation + #shipto_id => { + # sub => sub { $_[0]->shipto ? $_[0]->shipto->shiptoname : '' }, + #}, + amount => { + sub => sub { $_[0]->amount_as_number }, + }, + netamount => { + sub => sub { $_[0]->netamount_as_number }, + }, + delivery_term => { + obj_link => sub { $_[0]->delivery_term_id ? + $self->url_for( + controller => "controller.pl", + action => 'DeliveryTerm/edit', + id => $_[0]->delivery_term_id, + callback => $callback + ) : '' }, + sub => sub { $_[0]->delivery_term ? $_[0]->delivery_term->description : '' }, + }, + payment => { + obj_link => sub { $_[0]->payment_id ? + $self->url_for( + controller => "controller.pl", + action => 'PaymentTerm/edit', + id => $_[0]->payment_id, + callback => $callback + ) : '' }, + sub => sub { $_[0]->payment ? $_[0]->payment->description : '' }, + }, + currency => { + sub => sub { $_[0]->currency ? $_[0]->currency->name : '' }, + }, + exchangerate => { + sub => sub { $_[0]->exchangerate ? $_[0]->exchangerate_as_number : '' }, + }, + taxincluded => { + sub => sub { $_[0]->taxincluded ? t8('Yes') : t8('No') }, + }, + taxzone => { + obj_link => sub { $_[0]->taxzone_id ? + $self->url_for( + controller => "controller.pl", + action => 'Taxzones/edit', + id => $_[0]->taxzone_id, + callback => $callback + ) : '' }, + sub => sub { $_[0]->taxzone ? $_[0]->taxzone->description : '' }, + }, + tax_point => { + sub => sub { $_[0]->tax_point ? ($_[0]->tax_point)->to_kivitendo(precision => 'day') : '' }, + }, + reqdate => { + sub => sub { $_[0]->reqdate ? ($_[0]->reqdate)->to_kivitendo(precision => 'day') : '' }, + }, + transdate => { + sub => sub { $_[0]->transdate ? ($_[0]->transdate)->to_kivitendo(precision => 'day') : '' }, + }, + itime => { + sub => sub { $_[0]->itime->to_kivitendo(precision => 'minute') } + }, + mtime => { + sub => sub { $_[0]->mtime ? $_[0]->mtime->to_kivitendo(precision => 'minute') : '' } + }, + delivered => { + sub => sub { $_[0]->delivered ? t8('Yes') : t8('No') }, + }, + closed => { + sub => sub { $_[0]->closed ? t8('Yes') : t8('No') }, + }, + ); + if ($self->type_data->properties('is_customer')) { + $column_defs{customer} = ({ + raw_data => sub { $_[0]->customervendor->presenter->customer(display => 'table-cell', callback => $callback) }, + sub => sub { $_[0]->customervendor->name }, + }); + $column_defs{contact} = ({ + obj_link => sub { $self->url_for( + controller => "controller.pl", + action => 'CustomerVendor/edit', + db => 'customer', + id => $_[0]->customer_id + ) . '#contacts' + }, + sub => sub { $_[0]->contact ? $_[0]->contact->cp_name : '' }, + }); + } else { + $column_defs{vendor} = ({ + raw_data => sub { $_[0]->customervendor->presenter->vendor(display => 'table-cell', callback => $callback) }, + sub => sub { $_[0]->customervendor->name }, + }); + $column_defs{contact} = ({ + obj_link => sub { $self->url_for( + controller => "controller.pl", + action => 'CustomerVendor/edit', + db => 'vendor', + id => $_[0]->vendor_id + ) . "#contacts" + }, + sub => sub { $_[0]->contact ? $_[0]->contact->cp_name : '' }, + }); + } + $column_defs{$_}->{text} ||= t8( $self->models->get_sort_spec->{$_}->{title} || $_ ) for keys %column_defs; + + unless ($::form->{active_in_report}) { + $::form->{active_in_report}->{$_} = 1 foreach @default_columns; + } + $self->models->add_additional_url_params( + active_in_report => $::form->{active_in_report}); + map { $column_defs{$_}->{visible} = $::form->{active_in_report}->{"$_"} } + keys %column_defs; + + ## add cvars TODO: Add own cvars + #my %cvar_column_defs = map { + # my $cfg = $_; + # (('cvar_' . $cfg->name) => { + # sub => sub { my $var = $_[0]->cvar_by_name($cfg->name); $var ? $var->value_as_text : '' }, + # text => $cfg->description, + # visible => $self->include_cvars->{ $cfg->name } ? 1 : 0, + # }) + #} @{ $self->includeable_cvar_configs }; + + #push @columns, map { 'cvar_' . $_->name } @{ $self->includeable_cvar_configs }; + #%column_defs = (%column_defs, %cvar_column_defs); + + #my @cvar_column_form_names = ('_include_cvars_from_form', map { "include_cvars_" . $_->name } @{ $self->includeable_cvar_configs }); + + # make all sortable + my @sortable = keys %column_defs; + + my $filter_html = SL::Presenter::ReclamationFilter::filter( + $::form->{filter}, $self->type, active_in_report => $::form->{active_in_report} + ); + + $report->set_options( + std_column_visibility => 1, + controller_class => 'Reclamation', + output_format => 'HTML', + raw_top_info_text => $self->render( + 'reclamation/_report_top', + { output => 0 }, + FILTER_HTML => $filter_html, + ), + raw_bottom_info_text => $self->render( + 'reclamation/_report_bottom', + { output => 0 }, + models => $self->models + ), + title => $self->type_data->text('list'), + allow_pdf_export => 1, + allow_csv_export => 1, + ); + $report->set_columns(%column_defs); + $report->set_column_order(@columns_order); + #$report->set_export_options(qw(list filter), @cvar_column_form_names); TODO: for cvars + $report->set_export_options(qw(list filter active_in_report)); + $report->set_options_from_form; + $self->models->set_report_generator_sort_options(report => $report, sortable_columns => \@sortable); +} + +sub _setup_edit_action_bar { + my ($self, %params) = @_; + + for my $bar ($::request->layout->get('actionbar')) { + $bar->add( + combobox => [ + action => [ + t8('Save'), + call => [ 'kivi.Reclamation.save', { + action => 'save', + warn_on_duplicates => $::instance_conf->get_reclamation_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_reclamation_warn_no_reqdate, + }], + checks => [ + ['kivi.validate_form','#reclamation_form'], + ], + ], + action => [ + t8('Save and Close'), + call => [ 'kivi.Reclamation.save', { + action => 'save', + warn_on_duplicates => $::instance_conf->get_reclamation_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_reclamation_warn_no_reqdate, + form_params => [ + { name => 'back_to_caller', value => 1 }, + ], + }], + checks => [ + ['kivi.validate_form','#reclamation_form'], + ], + ], + action => [ + t8('Save as new'), + call => [ 'kivi.Reclamation.save', { + action => 'save_as_new', + warn_on_duplicates => $::instance_conf->get_reclamation_warn_duplicate_parts, + }], + disabled => !$self->reclamation->id ? t8('This object has not been saved yet.') : undef, + ], + ], # end of combobox "Save" + + combobox => [ + action => [ + t8('Workflow'), + ], + action => [ + t8('Save and Sales Reclamation'), + call => [ 'kivi.Reclamation.save', { + action => 'save_and_new_record', + warn_on_duplicates => $::instance_conf->get_reclamation_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_reclamation_warn_no_reqdate, + form_params => [ + { name => 'to_type', value => SALES_RECLAMATION_TYPE() }, + ], + }], + only_if => $self->type_data->show_menu('save_and_sales_reclamation'), + ], + action => [ + t8('Save and Purchase Reclamation'), + call => [ 'kivi.Reclamation.purchase_reclamation_check_for_direct_delivery', { + action => 'save_and_new_record', + warn_on_duplicates => $::instance_conf->get_reclamation_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_reclamation_warn_no_reqdate, + form_params => [ + { name => 'to_type', value => PURCHASE_RECLAMATION_TYPE() }, + ], + } + ], + only_if => $self->type_data->show_menu('save_and_purchase_reclamation'), + ], + action => [ + t8('Save and Order'), + call => [ 'kivi.Reclamation.save', { + action => 'save_and_new_record', + warn_on_duplicates => $::instance_conf->get_reclamation_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_reclamation_warn_no_reqdate, + form_params => [ + { name => 'to_type', + value => $self->reclamation->is_sales ? SALES_ORDER_TYPE() + : PURCHASE_ORDER_TYPE() }, + ], + }], + ], + action => [ + t8('Save and RMA Delivery Order'), + call => [ 'kivi.Reclamation.save', { + action => 'save_and_new_record', + warn_on_duplicates => $::instance_conf->get_reclamation_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_reclamation_warn_no_reqdate, + form_params => [ + { name => 'to_type', value => RMA_DELIVERY_ORDER_TYPE() }, + ], + }], + only_if => $self->type_data->show_menu('save_and_rma_delivery_order'), + ], + action => [ + t8('Save and Supplier Delivery Order'), + call => [ 'kivi.Reclamation.save', { + action => 'save_and_new_record', + warn_on_duplicates => $::instance_conf->get_reclamation_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_reclamation_warn_no_reqdate, + form_params => [ + { name => 'to_type', value => SUPPLIER_DELIVERY_ORDER_TYPE() }, + ], + }], + only_if => $self->type_data->show_menu('save_and_supplier_delivery_order'), + ], + action => [ + t8('Save and Credit Note'), + call => [ 'kivi.Reclamation.save', { + action => 'save_and_credit_note', + warn_on_duplicates => $::instance_conf->get_reclamation_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_reclamation_warn_no_reqdate, + form_params => [ + { name => 'to_type', value => 'credit_note' }, + ], + }], + only_if => $self->type_data->show_menu('save_and_credit_note'), + ], + ], # end of combobox "Workflow" + + combobox => [ + action => [ + t8('Export'), + ], + action => [ + t8('Save and preview PDF'), + call => [ 'kivi.Reclamation.save', { + action => 'preview_pdf', + warn_on_duplicates => $::instance_conf->get_reclamation_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_reclamation_warn_no_reqdate, + }], + ], + action => [ + t8('Save and print'), + call => [ + 'kivi.Reclamation.show_print_options', + $::instance_conf->get_reclamation_warn_duplicate_parts, + $::instance_conf->get_reclamation_warn_no_reqdate, + ], + ], + action => [ + t8('Save and E-mail'), + id => 'save_and_email_action', + call => [ 'kivi.Reclamation.save', { + action => 'save_and_show_email_dialog', + warn_on_duplicates => $::instance_conf->get_reclamation_warn_duplicate_parts, + warn_on_reqdate => $::instance_conf->get_reclamation_warn_no_reqdate, + }], + disabled => !$self->reclamation->id ? t8('This object has not been saved yet.') : undef, + ], + action => [ + t8('Download attachments of all parts'), + call => [ 'kivi.File.downloadReclamationitemsFiles', $::form->{type}, $::form->{id} ], + disabled => !$self->reclamation->id ? t8('This object has not been saved yet.') : undef, + only_if => $::instance_conf->get_doc_storage, + ], + ], # end of combobox "Export" + + action => [ + t8('Delete'), + call => [ 'kivi.Reclamation.delete_reclamation' ], + confirm => t8('Do you really want to delete this object?'), + disabled => !$self->reclamation->id ? t8('This object has not been saved yet.') : undef, + only_if => $self->type_data->show_menu('delete'), + ], + + combobox => [ + action => [ + t8('more') + ], + action => [ + t8('Follow-Up'), + call => [ 'kivi.Reclamation.follow_up_window' ], + disabled => !$self->reclamation->id ? t8('This object has not been saved yet.') : undef, + only_if => $::auth->assert('productivity', 1), + ], + action => [ + t8('History'), + call => [ 'set_history_window', $self->reclamation->id, 'id' ], + disabled => !$self->reclamation->id ? t8('This record has not been saved yet.') : undef, + ], + ], # end of combobox "more" + ); + } +} + +sub _setup_search_action_bar { + my ($self, %params) = @_; + + for my $bar ($::request->layout->get('actionbar')) { + $bar->add( + action => [ + t8('Update'), + submit => [ '#search_form', { action => 'Reclamation/list', type => $self->type } ], + accesskey => 'enter', + ], + link => [ + t8('Add'), + link => $self->url_for(action => 'add', type => $self->type), + ], + ); + } +} + +sub generate_pdf { + my ($reclamation, $pdf_ref, $params) = @_; + + my @errors = (); + + my $print_form = Form->new(''); + $print_form->{type} = $reclamation->type; + $print_form->{formname} = $params->{formname} || $reclamation->type; + $print_form->{format} = $params->{format} || 'pdf'; + $print_form->{media} = $params->{media} || 'file'; + $print_form->{groupitems} = $params->{groupitems}; + $print_form->{printer_id} = $params->{printer_id}; + $print_form->{language_id} = $params->{language} ? $params->{language}->id : undef; + $print_form->{media} = 'file' if $print_form->{media} eq 'screen'; + + $reclamation->language($params->{language}); + + # Make reclamation available in template + $print_form->{reclamation} = $reclamation; + + my $template_ext; + my $template_type; + my $variable_content_types; + if ($print_form->{format} =~ /(opendocument|oasis)/i) { + $template_ext = 'odt'; + $template_type = 'OpenDocument'; + + # add variables for printing with the built-in parser + $reclamation->flatten_to_form($print_form, format_amounts => 1); + $reclamation->add_legacy_template_arrays($print_form); + + $variable_content_types = { + longdescription => 'html', + notes => 'html', + $::form->get_variable_content_types_for_cvars, + } + } + + # search for the template + my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template( + name => $print_form->{formname}, + extension => $template_ext, + email => $print_form->{media} eq 'email', + language => $params->{language}, + printer_id => $print_form->{printer_id}, + ); + + if (!defined $template_file) { + push @errors, t8( + 'Cannot find matching template for this print request. Please contact your template maintainer. I tried these: #1.', + join ', ', + map { "'$_'"} @template_files + ); + } + + return @errors if scalar @errors; + + $print_form->throw_on_error(sub { + eval { + $print_form->prepare_for_printing; + + $$pdf_ref = SL::Helper::CreatePDF->create_pdf( + format => $print_form->{format}, + template_type => $template_type, + template => $template_file, + variables => $print_form, + variable_content_types => $variable_content_types, + ); + 1; + } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR; + }); + + return @errors; +} + +sub get_files_for_email_dialog { + my ($self) = @_; + + my %files = map { ($_ => []) } qw(versions files cv_files project_files part_files); + + return %files if !$::instance_conf->get_doc_storage; + + if ($self->reclamation->id) { + $files{versions} = [ + SL::File->get_all_versions( + object_id => $self->reclamation->id, + object_type => $self->reclamation->type, + file_type => 'document') + ]; + $files{files} = [ + SL::File->get_all( + object_id => $self->reclamation->id, + object_type => $self->reclamation->type, + file_type => 'attachment') + ]; + $files{cv_files} = [ + SL::File->get_all( + object_id => $self->reclamation->customervendor->id, + object_type => $self->cv, + file_type => 'attachment') + ]; + $files{project_files} = [ + SL::File->get_all( + object_id => $self->reclamation->globalproject_id, + object_type => 'project', + file_type => 'attachment') + ]; + } + + my @parts = + uniq_by { $_->{id} } + map { + +{ id => $_->part->id, + partnumber => $_->part->partnumber } + } @{$self->reclamation->items_sorted}; + + foreach my $part (@parts) { + my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part'); + push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles; + } + + foreach my $key (keys %files) { + $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ]; + } + + return %files; +} + +sub get_item_cvpartnumber { + my ($self, $item) = @_; + + return if !$self->search_cvpartnumber; + return if !$self->reclamation->customervendor; + + if (!$self->reclamation->is_sales) { + my @mms = grep { $_->make eq $self->reclamation->customervendor->id } @{$item->part->makemodels}; + $item->{cvpartnumber} = $mms[0]->model if scalar @mms; + } elsif ($self->reclamation->is_sales) { + my @cps = grep { $_->customer_id eq $self->reclamation->customervendor->id } @{$item->part->customerprices}; + $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps; + } +} + +sub get_part_texts { + my ($part_or_id, $language_or_id, %defaults) = @_; + + my $part = ref($part_or_id) ? $part_or_id : SL::DB::Part->load_cached($part_or_id); + my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id; + my $texts = { + description => $defaults{description} // $part->description, + longdescription => $defaults{longdescription} // $part->notes, + }; + + return $texts unless $language_id; + + my $translation = SL::DB::Manager::Translation->get_first( + where => [ + parts_id => $part->id, + language_id => $language_id, + ]); + + $texts->{description} = $translation->translation if $translation && $translation->translation; + $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription; + + return $texts; +} + +sub save_history { + my ($self, $addition) = @_; + + SL::DB::History->new( + trans_id => $self->reclamation->id, + employee_id => SL::DB::Manager::Employee->current->id, + what_done => $self->reclamation->type, + snumbers => 'record_number_' . $self->reclamation->record_number, + addition => $addition, + )->save; +} + +sub store_pdf_to_webdav_and_filemanagement { + my($reclamation, $content, $filename) = @_; + + my @errors; + + # copy file to webdav folder + if ($reclamation->record_number && $::instance_conf->get_webdav_documents) { + my $webdav = SL::Webdav->new( + type => $reclamation->type, + number => $reclamation->record_number, + ); + my $webdav_file = SL::Webdav::File->new( + webdav => $webdav, + filename => $filename, + ); + eval { + $webdav_file->store(data => \$content); + 1; + } or do { + push @errors, t8('Storing PDF to webdav folder failed: #1', $@); + }; + } + if ($reclamation->id && $::instance_conf->get_doc_storage) { + eval { + SL::File->save(object_id => $reclamation->id, + object_type => $reclamation->type, + mime_type => 'application/pdf', + source => 'created', + file_type => 'document', + file_name => $filename, + file_contents => $content); + 1; + } or do { + push @errors, t8('Storing PDF in storage backend failed: #1', $@); + }; + } + + return @errors; +} + +sub init_type_data { + my ($self) = @_; + SL::DB::Helper::TypeDataProxy->new('SL::DB::Reclamation', $self->reclamation->record_type); +} + +1; + +__END__ + +=encoding utf-8 + +=head1 NAME + +SL::Controller::Reclamation - controller for reclamations + +=head1 SYNOPSIS + +This is a new form to enter reclamations, written with the use +of controller and java script techniques. + +The aim is to provide the user a good experience and a fast workflow. + +=head2 Key Features + +=over 4 + +=item * + +One input row, so that input happens every time at the same place. + +=item * + +Use of pickers where possible. + +=item * + +Possibility to enter more than one item at once. + +=item * + +Item list in a scrollable area, so that the workflow buttons stay at +the bottom. + +=item * + +Ordering item rows with drag and drop is possible. Sorting item rows is +possible (by partnumber, description, reason, qty, sellprice +and discount for now). + +=item * + +No C is necessary. All entries and calculations are managed +with ajax-calls and the page only reloads on C. + +=item * + +User can see changes immediately, because of the use of java script +and ajax. + +=item * + +Parts that are linked though RecordLinks are protected against price editing. + +=back + +=head1 CODE + +=head2 Layout + +=over 4 + +=item * C + +the controller + +=item * C