]> wagnertech.de Git - mfinanz.git/commitdiff
Merge branch 'f-factur-x-und-xrechnung'
authorMoritz Bunkus <m.bunkus@linet.de>
Thu, 10 Dec 2020 14:58:08 +0000 (15:58 +0100)
committerMoritz Bunkus <m.bunkus@linet.de>
Thu, 10 Dec 2020 14:58:08 +0000 (15:58 +0100)
53 files changed:
.mailmap
SL/AP.pm
SL/AR.pm
SL/BackgroundJob/CreatePeriodicInvoices.pm
SL/Controller/BankTransaction.pm
SL/Controller/Order.pm
SL/DATEV.pm
SL/DB/BankTransaction.pm
SL/DB/Helper/FlattenToForm.pm
SL/DB/Helper/PriceTaxCalculator.pm
SL/DB/Invoice.pm
SL/DB/MetaSetup/DeliveryOrder.pm
SL/DB/MetaSetup/GLTransaction.pm
SL/DB/MetaSetup/Invoice.pm
SL/DB/MetaSetup/Order.pm
SL/DB/MetaSetup/PurchaseInvoice.pm
SL/DB/MetaSetup/RecordTemplate.pm
SL/DB/Order.pm
SL/DB/PurchaseInvoice.pm
SL/DO.pm
SL/Form.pm
SL/GL.pm
SL/Helper/Inventory.pm [new file with mode: 0644]
SL/Helper/Inventory/Allocation.pm [new file with mode: 0644]
SL/IC.pm
SL/IR.pm
SL/IS.pm
SL/LXDebug.pm
SL/OE.pm
SL/Presenter/Tag.pm
SL/X.pm
bin/mozilla/ap.pl
bin/mozilla/gl.pl
bin/mozilla/io.pl
bin/mozilla/oe.pl
bin/mozilla/sepa.pl
doc/changelog
locale/de/all
sql/Pg-upgrade2/record_template_payment_id.sql [new file with mode: 0644]
sql/Pg-upgrade2/tax_point.sql [new file with mode: 0644]
sql/Pg-upgrade2/tax_point2.sql [new file with mode: 0644]
t/bank/bank_transactions.t
t/wh/inventory.t [new file with mode: 0644]
templates/print/marei/kiviletter.sty
templates/webpages/ap/form_header.html
templates/webpages/ar/form_header.html
templates/webpages/do/form_header.html
templates/webpages/gl/form_header.html
templates/webpages/inventory/stocktaking/form.html
templates/webpages/ir/form_header.html
templates/webpages/is/form_header.html
templates/webpages/oe/form_header.html
templates/webpages/order/tabs/basic_data.html

index bf5a206aa14c3fcdb55ee33c95ced934ad35e2a8..bfb19e55b66615d255e2c25609fbc6e9dfa310e2 100644 (file)
--- a/.mailmap
+++ b/.mailmap
@@ -1,7 +1,9 @@
+Bernd Bleßmann <bernd@kivitendo-premium.de> <bb@it-entwicklung.de>
 Bernd Bleßmann <bernd@kivitendo-premium.de> <bibi@online.de>
 Bernd Bleßmann <bernd@kivitendo-premium.de> bernd <bernd@lxbug.(none)>
 Christian Wittmer <chris@computersalat.de> ChrisWi <chris@computersalat.de>
 Geoffrey Richardson <information@kivitendo-premium.de>
+Geoffrey Richardson <information@kivitendo-premium.de> G. Richardson <grichardson@kivitec.de>
 Geoffrey Richardson <information@kivitendo-premium.de> <information@lx-office-hosting.de>
 Geoffrey Richardson <information@kivitendo-premium.de> <information@richardson-bueren.de>
 Geoffrey Richardson <information@kivitendo-premium.de> grichardson <gr@richardson-bueren.de>
@@ -9,22 +11,29 @@ Holger Lindemann <hli@lx-system.de> <hli@debian7.lx-system.de>
 Holger Lindemann <hli@lx-system.de> <hli@lenny.hoch.ul>
 Jan Büren <jan@kivitendo-premium.de> <jan@baobab.intranet.xplace.de>
 Jan Büren <jan@kivitendo-premium.de> <jan@circa-support.eu>
+Jan Büren <jan@kivitendo-premium.de> <jan@echinacea.es>
+Jan Büren <jan@kivitendo-premium.de> <jan@kivitendo.de>
 Jan Büren <jan@kivitendo-premium.de> <jan@lx-office-hosting.de>
 Jan Büren <jan@kivitendo-premium.de> <jan@lx-office-premium.de>
 Jan Büren <jan@kivitendo-premium.de> <jan@richardson-bueren.de>
 Jan Büren <jan@kivitendo-premium.de> <jan@weitan.org>
 Jan Büren <jan@kivitendo-premium.de> <root@vc-kivi.vitracom.org>
 Joachim Zach <joachim@lx-office-hosting.de> <info@ceos-gmbh.de>
+Marei Peischl <marei@peitex.de> Marei (peiTeX) <marei@peitex.de>
+Marei Peischl <marei@peitex.de> Marei Peischl (peiTeX) <marei@peitex.de>
 Martin Helmling <martin.helmling@octosoft.eu> <MartinHelmling@octo-soft.de>
 Martin Helmling <martin.helmling@octosoft.eu> <mh@waldpark.octosoft.eu>
 Martin Helmling <martin.helmling@octosoft.eu> Martin Helmling martin.helmling@octosoft.eu
 Martin Helmling <martin.helmling@octosoft.eu> Martin Helmling mh@waldpark.octosoft.eu <martin.helmling@octosoft.eu>
-Moritz Bunkus <m.bunkus@linet-services.de> <moritz@bunkus.org>
+Moritz Bunkus <m.bunkus@linet.de> <moritz@bunkus.org>
+Moritz Bunkus <m.bunkus@linet.de> <m.bunkus@linet-services.de>
 Niclas Zimmermann <niclas@kivitendo-premium.de> <niclas@lx-office-hosting.de>
+Rolf Eike Beer <dakon@users.sf.net> Rolf Eike Beer <eike@sf-mail.de>
 Roman Karuschka <karuschka@ok-it-services.de> R. Karuschka <r.karuschka@ok-it-services.de>
 Roman Karuschka <karuschka@ok-it-services.de> Roman Karushka <karuschka@ok-it-services.de>
 Roman Karuschka <karuschka@ok-it-services.de> roman <roman@omega.ok-it-services.de>
-Sven Schöling <s.schoeling@linet-services.de>
+Sven Schöling <s.schoeling@googlemail.com> <s.schoeling@linet-services.de>
+Sven Schöling <s.schoeling@googlemail.com> <sven.schoeling@opendynamic.de>
 Timo Eickmeyer <timo@kivitendo-premium.de> T. Eickmeyer <timo@kivitendo-premium.de>
 Waldemar Toews <waldemar.toews@opendynamic.de> <toews@erp-d300.opendynamic.local>
 Wulf Coulmann <wulf@coulmann.de> Wulf <wulf@coulmann.de>
index 937af8b5845a57c38b328cd60b1c0dd08d4cf2a1..71c2b4b004ce53a28d79879b56caae21a20b642c 100644 (file)
--- a/SL/AP.pm
+++ b/SL/AP.pm
@@ -139,14 +139,14 @@ sub _post_transaction {
 
     $query = qq|UPDATE ap SET invnumber = ?,
                 transdate = ?, ordnumber = ?, vendor_id = ?, taxincluded = ?,
-                amount = ?, duedate = ?, deliverydate = ?, paid = ?, netamount = ?,
+                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 = ?
                WHERE id = ?|;
     @values = ($form->{invnumber}, conv_date($form->{transdate}),
                   $form->{ordnumber}, conv_i($form->{vendor_id}),
                   $form->{taxincluded} ? 't' : 'f', $form->{invtotal},
-                  conv_date($form->{duedate}), conv_date($form->{deliverydate}),
+                  conv_date($form->{duedate}), conv_date($form->{deliverydate}), conv_date($form->{tax_point}),
                   $form->{invpaid}, $form->{netamount},
                   $form->{currency}, $form->{notes},
                   conv_i($form->{department_id}), $form->{storno},
index 3d31a61a4188ec9634348771cd5c424cc6c7eb9f..b856f44a73e1c70007bf7e4b8d7bf26069e94d5c 100644 (file)
--- a/SL/AR.pm
+++ b/SL/AR.pm
@@ -134,14 +134,14 @@ sub _post_transaction {
     $query =
       qq|UPDATE ar set
            invnumber = ?, ordnumber = ?, transdate = ?, customer_id = ?,
-           taxincluded = ?, amount = ?, duedate = ?, deliverydate = ?, paid = ?,
+           taxincluded = ?, amount = ?, duedate = ?, deliverydate = ?, tax_point = ?, paid = ?,
            currency_id = (SELECT id FROM currencies WHERE name = ?),
            netamount = ?, notes = ?, department_id = ?,
            employee_id = ?, storno = ?, storno_id = ?, globalproject_id = ?,
            direct_debit = ?
          WHERE id = ?|;
     my @values = ($form->{invnumber}, $form->{ordnumber}, conv_date($form->{transdate}), conv_i($form->{customer_id}), $form->{taxincluded} ? 't' : 'f', $form->{amount},
-                  conv_date($form->{duedate}), conv_date($form->{deliverydate}), $form->{paid},
+                  conv_date($form->{duedate}), conv_date($form->{deliverydate}), conv_date($form->{tax_point}), $form->{paid},
                   $form->{currency},
                   $form->{netamount}, $form->{notes}, conv_i($form->{department_id}),
                   conv_i($form->{employee_id}), $form->{storno} ? 't' : 'f', $form->{storno_id},
index 52415f20e2f19832cb9a1055c27b5158511fffc2..f7b9100ad93a24b4e4004286e31c4e4c8c0ada83 100644 (file)
@@ -224,10 +224,17 @@ sub _create_periodic_invoice {
 
     $invoice = SL::DB::Invoice->new_from($order);
 
+    my $tax_point = ($invoice->tax_point // $time_period_vars->{period_end_date}->[0])->clone;
+
+    while ($tax_point < $period_start_date) {
+      $tax_point->add(months => $config->get_billing_period_length);
+    }
+
     my $intnotes  = $invoice->intnotes ? $invoice->intnotes . "\n\n" : '';
     $intnotes    .= "Automatisch am " . $invdate->to_lxoffice . " erzeugte Rechnung";
 
     $invoice->assign_attributes(deliverydate => $period_start_date,
+                                tax_point    => $tax_point,
                                 intnotes     => $intnotes,
                                 employee     => $order->employee, # new_from sets employee to import user
                                 direct_debit => $config->direct_debit,
index dc66ef090388e99f39bea902cf7c3cf0c6db22b8..e7137bb380b538ab33d46d65fb4566d780380330 100644 (file)
@@ -206,13 +206,10 @@ sub gather_bank_transactions_and_proposals {
   # to qualify as a proposal there has to be
   # * agreement >= 5  TODO: make threshold configurable in configuration
   # * there must be only one exact match
-  # * depending on whether sales or purchase the amount has to have the correct sign (so Gutschriften don't work?)
   my $proposal_threshold = 5;
   my @otherproposals = grep {
        ($_->{agreement} >= $proposal_threshold)
     && (1 == scalar @{ $_->{proposals} })
-    && (@{ $_->{proposals} }[0]->is_sales ? abs(@{ $_->{proposals} }[0]->amount - $_->amount) < 0.01
-                                          : abs(@{ $_->{proposals} }[0]->amount + $_->amount) < 0.01)
   } @{ $bank_transactions };
 
   push @proposals, @otherproposals;
index 29ff0ac49e7f7d52a1383f27d8e81a751a7274a1..b51802f629835ba7f9852e650fe6e1b7d3f9c876 100644 (file)
@@ -581,27 +581,10 @@ sub action_get_has_active_periodic_invoices {
 sub action_save_and_delivery_order {
   my ($self) = @_;
 
-  my $errors = $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);
-
-  my @redirect_params = (
+  $self->save_and_redirect_to(
     controller => 'oe.pl',
     action     => 'oe_delivery_order_from_order',
-    id         => $self->order->id,
   );
-
-  $self->redirect_to(@redirect_params);
 }
 
 # save the order and redirect to the frontend subroutine for a new
@@ -609,27 +592,10 @@ sub action_save_and_delivery_order {
 sub action_save_and_invoice {
   my ($self) = @_;
 
-  my $errors = $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);
-
-  my @redirect_params = (
+  $self->save_and_redirect_to(
     controller => 'oe.pl',
     action     => 'oe_invoice_from_order',
-    id         => $self->order->id,
   );
-
-  $self->redirect_to(@redirect_params);
 }
 
 # workflow from sales quotation to sales order
@@ -646,27 +612,10 @@ sub action_purchase_order {
 sub action_save_and_ap_transaction {
   my ($self) = @_;
 
-  my $errors = $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);
-
-  my @redirect_params = (
+  $self->save_and_redirect_to(
     controller => 'ap.pl',
     action     => 'add_from_purchase_order',
-    id         => $self->order->id,
   );
-
-  $self->redirect_to(@redirect_params);
 }
 
 # set form elements in respect to a changed customer or vendor
@@ -2111,6 +2060,26 @@ sub 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();
+  }
+
+  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);
+
+  $self->redirect_to(%params, id => $self->order->id);
+}
+
 1;
 
 __END__
index af188f70a4e911f433464217e59deaac3c146fef..c6ce5b2a099e80d7d5c64587ff240144f5d5ce41 100644 (file)
@@ -517,7 +517,7 @@ sub generate_datev_data {
 
   my $query    =
     qq|SELECT ac.acc_trans_id, ac.transdate, ac.gldate, ac.trans_id,ar.id, ac.amount, ac.taxkey, ac.memo,
-         ar.invnumber, ar.duedate, ar.amount as umsatz, ar.deliverydate, ar.itime::date,
+         ar.invnumber, ar.duedate, ar.amount as umsatz, COALESCE(ar.tax_point, ar.deliverydate) AS deliverydate, ar.itime::date,
          ct.name, ct.ustid, ct.customernumber AS vcnumber, ct.id AS customer_id, NULL AS vendor_id,
          $ar_accno, c.description AS accname, c.taxkey_id as charttax, c.datevautomatik, c.id, ac.chart_link AS link,
          ar.invoice,
@@ -546,7 +546,7 @@ sub generate_datev_data {
        UNION ALL
 
        SELECT ac.acc_trans_id, ac.transdate, ac.gldate, ac.trans_id,ap.id, ac.amount, ac.taxkey, ac.memo,
-         ap.invnumber, ap.duedate, ap.amount as umsatz, ap.deliverydate, ap.itime::date,
+         ap.invnumber, ap.duedate, ap.amount as umsatz, COALESCE(ap.tax_point, ap.deliverydate) AS deliverydate, ap.itime::date,
          ct.name, ct.ustid, ct.vendornumber AS vcnumber, NULL AS customer_id, ct.id AS vendor_id,
          $ap_accno, c.description AS accname, c.taxkey_id as charttax, c.datevautomatik, c.id, ac.chart_link AS link,
          ap.invoice,
@@ -575,7 +575,7 @@ sub generate_datev_data {
        UNION ALL
 
        SELECT ac.acc_trans_id, ac.transdate, ac.gldate, ac.trans_id,gl.id, ac.amount, ac.taxkey, ac.memo,
-         gl.reference AS invnumber, NULL AS duedate, ac.amount as umsatz, gl.deliverydate, gl.itime::date,
+         gl.reference AS invnumber, NULL AS duedate, ac.amount as umsatz, COALESCE(gl.tax_point, gl.deliverydate) AS deliverydate, gl.itime::date,
          gl.description AS name, NULL as ustid, '' AS vcname, NULL AS customer_id, NULL AS vendor_id,
          c.accno, c.description AS accname, c.taxkey_id as charttax, c.datevautomatik, c.id, ac.chart_link AS link,
          FALSE AS invoice,
index e2ddede4e687833240c195d722670be6ce39ad31..c02ebb93e76c9c94437421bc69498ffccee9f086 100644 (file)
@@ -79,7 +79,7 @@ sub get_agreement_with_invoice {
     payment_within_30_days      => 1,
     remote_account_number       => 3,
     skonto_exact_amount         => 5,
-    wrong_sign                  => -1,
+    wrong_sign                  => -4,
     sepa_export_item            => 5,
     batch_sepa_transaction      => 20,
   );
@@ -162,11 +162,13 @@ sub get_agreement_with_invoice {
   }
 
   #check sign
-  if ( $invoice->is_sales && $self->amount < 0 ) {
+  if (( $invoice->is_sales && $invoice->amount > 0 && $self->amount < 0 ) ||
+      ( $invoice->is_sales && $invoice->amount < 0 && $self->amount > 0 )     ) { # sales credit note
     $agreement += $points{wrong_sign};
     $rule_matches .= 'wrong_sign(' . $points{'wrong_sign'} . ') ';
   }
-  if ( ! $invoice->is_sales && $self->amount > 0 ) {
+  if (( !$invoice->is_sales && $invoice->amount > 0 && $self->amount > 0)  ||
+      ( !$invoice->is_sales && $invoice->amount < 0 && $self->amount < 0)     ) { # purchase credit note
     $agreement += $points{wrong_sign};
     $rule_matches .= 'wrong_sign(' . $points{'wrong_sign'} . ') ';
   }
index 85591c07d30d3522a169282a8ba61ae0d4b96072..ccd6ca045b10e138d1e236d6d698fafc499457dc 100644 (file)
@@ -15,7 +15,7 @@ sub flatten_to_form {
   _copy($self, $form, '', '', 0, qw(id type taxzone_id ordnumber quonumber invnumber donumber cusordnumber taxincluded shippingpoint shipvia notes intnotes cp_id
                                     employee_id salesman_id closed department_id language_id payment_id delivery_customer_id delivery_vendor_id shipto_id proforma
                                     globalproject_id delivered transaction_description container_type accepted_by_customer invoice storno storno_id dunning_config_id
-                                    orddate quodate reqdate gldate duedate deliverydate datepaid transdate delivery_term_id));
+                                    orddate quodate reqdate gldate duedate deliverydate datepaid transdate tax_point delivery_term_id));
   $form->{currency} = $form->{curr} = $self->currency_id ? $self->currency->name || '' : '';
 
   if ( $vc eq 'customer' ) {
index f5bc61c4a78b06887ab9b4615837d0bb4e43d51f..15850d558946b1bb748f4a6d14bb5af333f9cba8 100644 (file)
@@ -54,7 +54,7 @@ sub calculate_prices_and_taxes {
   $self->netamount(  0);
   $self->marge_total(0);
 
-  SL::DB::Manager::Chart->cache_taxkeys(date => $self->deliverydate // $self->transdate);
+  SL::DB::Manager::Chart->cache_taxkeys(date => $self->effective_tax_point);
 
   my $idx = 0;
   foreach my $item (@{ $self->items_sorted }) {
@@ -110,7 +110,7 @@ sub _calculate_item {
 
   $data->{invoicediff} += $sellprice * (1 - $item->discount) * $item->qty * $data->{exchangerate} / $item->price_factor - $linetotal if $self->taxincluded;
 
-  my $taxkey     = $part->get_taxkey(date => $self->deliverydate // $self->transdate, is_sales => $data->{is_sales}, taxzone => $self->taxzone_id);
+  my $taxkey     = $part->get_taxkey(date => $self->effective_tax_point, is_sales => $data->{is_sales}, taxzone => $self->taxzone_id);
   my $tax_rate   = $taxkey->tax->rate;
   my $tax_amount = undef;
 
index 93c17e4ec819d3c3fdc72d235bcef4e19ce81331..26faca1a36f70811b7b200c77147d83d64fbdcb5 100644 (file)
@@ -156,7 +156,7 @@ sub new_from {
   my (@columns, @item_columns, $item_parent_id_column, $item_parent_column);
 
   if (ref($source) eq 'SL::DB::Order') {
-    @columns      = qw(quonumber delivery_customer_id delivery_vendor_id);
+    @columns      = qw(quonumber delivery_customer_id delivery_vendor_id tax_point);
     @item_columns = qw(subtotal);
 
     $item_parent_id_column = 'trans_id';
@@ -173,7 +173,7 @@ sub new_from {
   $terms = $source->customer->payment_terms if !defined $terms && $source->customer;
 
   my %args = ( map({ ( $_ => $source->$_ ) } qw(customer_id taxincluded shippingpoint shipvia notes intnotes salesman_id cusordnumber ordnumber department_id
-                                                cp_id language_id taxzone_id globalproject_id transaction_description currency_id delivery_term_id), @columns),
+                                                cp_id language_id taxzone_id tax_point globalproject_id transaction_description currency_id delivery_term_id), @columns),
                transdate   => $params{transdate} // DateTime->today_local,
                gldate      => DateTime->today_local,
                duedate     => $terms ? $terms->calc_date(reference_date => DateTime->today_local) : DateTime->today_local,
@@ -609,6 +609,12 @@ sub mark_as_paid {
   $self->update_attributes(paid => $self->amount);
 }
 
+sub effective_tax_point {
+  my ($self) = @_;
+
+  return $self->tax_point || $self->deliverydate || $self->transdate;
+}
+
 1;
 
 __END__
index d242fcd3cd7b6b32c3401b4be62b12608ad057f2..780f4318c638e2d0a5dfb2042fc5bd065a8a8898 100644 (file)
@@ -35,6 +35,7 @@ __PACKAGE__->meta->columns(
   shippingpoint           => { type => 'text' },
   shipto_id               => { type => 'integer' },
   shipvia                 => { type => 'text' },
+  tax_point               => { type => 'date' },
   taxincluded             => { type => 'boolean' },
   taxzone_id              => { type => 'integer', not_null => 1 },
   transaction_description => { type => 'text' },
index f5925136219366ada2fcda75a9568739a0e886e6..4d0e41b1371de131b473af06510cd92c06663a18 100644 (file)
@@ -23,6 +23,7 @@ __PACKAGE__->meta->columns(
   reference      => { type => 'text' },
   storno         => { type => 'boolean', default => 'false' },
   storno_id      => { type => 'integer' },
+  tax_point      => { type => 'date' },
   taxincluded    => { type => 'boolean' },
   transdate      => { type => 'date', default => 'now' },
   type           => { type => 'text' },
index b88c2f57c087afe3a6562c95709d8ce42b4ff9bb..9b10c938c6992b8d3880440a1350fffe7531917d 100644 (file)
@@ -51,6 +51,7 @@ __PACKAGE__->meta->columns(
   shipvia                   => { type => 'text' },
   storno                    => { type => 'boolean', default => 'false' },
   storno_id                 => { type => 'integer' },
+  tax_point                 => { type => 'date' },
   taxincluded               => { type => 'boolean' },
   taxzone_id                => { type => 'integer', not_null => 1 },
   transaction_description   => { type => 'text' },
index d6922bd530c58ec4d361a04fd9793868d4277e2a..6e707ae6589183fad308958902fddf0fb69daaa2 100644 (file)
@@ -44,6 +44,7 @@ __PACKAGE__->meta->columns(
   shippingpoint           => { type => 'text' },
   shipto_id               => { type => 'integer' },
   shipvia                 => { type => 'text' },
+  tax_point               => { type => 'date' },
   taxincluded             => { type => 'boolean' },
   taxzone_id              => { type => 'integer', not_null => 1 },
   transaction_description => { type => 'text' },
index c9c74f0d493172d96d75e0448b8fe7b2162add1e..4a443ac619b78f684241429e45d87d1dfe87d600 100644 (file)
@@ -39,6 +39,7 @@ __PACKAGE__->meta->columns(
   shipvia                 => { type => 'text' },
   storno                  => { type => 'boolean', default => 'false' },
   storno_id               => { type => 'integer' },
+  tax_point               => { type => 'date' },
   taxincluded             => { type => 'boolean', default => 'false' },
   taxzone_id              => { type => 'integer', not_null => 1 },
   transaction_description => { type => 'text' },
index a9f6f8814eda0b9cb65dbfada578dfe5483d251b..83850fffa4bd942a6f737e725bc9904e012a472f 100644 (file)
@@ -23,6 +23,7 @@ __PACKAGE__->meta->columns(
   notes          => { type => 'text' },
   ob_transaction => { type => 'boolean', default => 'false', not_null => 1 },
   ordnumber      => { type => 'text' },
+  payment_id     => { type => 'integer' },
   project_id     => { type => 'integer' },
   reference      => { type => 'text' },
   show_details   => { type => 'boolean', default => 'false', not_null => 1 },
@@ -62,6 +63,11 @@ __PACKAGE__->meta->foreign_keys(
     key_columns => { employee_id => 'id' },
   },
 
+  payment => {
+    class       => 'SL::DB::PaymentTerm',
+    key_columns => { payment_id => 'id' },
+  },
+
   project => {
     class       => 'SL::DB::Project',
     key_columns => { project_id => 'id' },
index d996d13b659175bea4bbce6114b388c0d260ac8e..8228def987684435e4c1234a8956ce1651532ac1 100644 (file)
@@ -139,9 +139,17 @@ sub is_type {
 }
 
 sub deliverydate {
-  # oe doesn't have deliverydate, but PTC checks for deliverydate or transdate to determine tax
-  # oe can't deal with deviating tax rates, but at least make sure PTC doesn't barf
-  return shift->transdate;
+  # oe doesn't have deliverydate, but it does have reqdate.
+  # But this has a different meaning for sales quotations.
+  # deliverydate can be used to determine tax if tax_point isn't set.
+
+  return $_[0]->reqdate if $_[0]->type ne 'sales_quotation';
+}
+
+sub effective_tax_point {
+  my ($self) = @_;
+
+  return $self->tax_point || $self->deliverydate || $self->transdate;
 }
 
 sub displayable_type {
@@ -322,7 +330,7 @@ sub new_from {
 
   my %args = ( map({ ( $_ => $source->$_ ) } qw(amount cp_id currency_id cusordnumber customer_id delivery_customer_id delivery_term_id delivery_vendor_id
                                                 department_id employee_id exchangerate globalproject_id intnotes marge_percent marge_total language_id netamount notes
-                                                ordnumber payment_id quonumber reqdate salesman_id shippingpoint shipvia taxincluded taxzone_id
+                                                ordnumber payment_id quonumber reqdate salesman_id shippingpoint shipvia taxincluded tax_point taxzone_id
                                                 transaction_description vendor_id
                                              )),
                quotation => !!($destination_type =~ m{quotation$}),
@@ -429,7 +437,7 @@ sub new_from_multi {
 
   # set this entries to undef that yield different information
   my %attributes;
-  foreach my $attr (qw(ordnumber transdate reqdate taxincluded shippingpoint
+  foreach my $attr (qw(ordnumber transdate reqdate tax_point taxincluded shippingpoint
                        shipvia notes closed delivered reqdate quonumber
                        cusordnumber proforma transaction_description
                        order_probability expected_billing_date)) {
index a50884cb1670f55b1eab4e8629ecb1e3f1ba7dfc..dd49433c6adb518e9638437439b64509ec7b69d8 100644 (file)
@@ -214,6 +214,12 @@ sub mark_as_paid {
   $self->update_attributes(paid => $self->amount);
 }
 
+sub effective_tax_point {
+  my ($self) = @_;
+
+  return $self->tax_point || $self->deliverydate || $self->transdate;
+}
+
 1;
 
 
index 034189fc347e1dd5c5d8afa559f5357876a781b5..90599a7de193e4b06685fc23d542c1badb856edd 100644 (file)
--- a/SL/DO.pm
+++ b/SL/DO.pm
@@ -511,7 +511,7 @@ SQL
   $query =
     qq|UPDATE delivery_orders SET
          donumber = ?, ordnumber = ?, cusordnumber = ?, transdate = ?, vendor_id = ?,
-         customer_id = ?, reqdate = ?,
+         customer_id = ?, reqdate = ?, tax_point = ?,
          shippingpoint = ?, shipvia = ?, notes = ?, intnotes = ?, closed = ?,
          delivered = ?, department_id = ?, language_id = ?, shipto_id = ?,
          globalproject_id = ?, employee_id = ?, salesman_id = ?, cp_id = ?, transaction_description = ?,
@@ -522,7 +522,7 @@ SQL
   @values = ($form->{donumber}, $form->{ordnumber},
              $form->{cusordnumber}, conv_date($form->{transdate}),
              conv_i($form->{vendor_id}), conv_i($form->{customer_id}),
-             conv_date($form->{reqdate}), $form->{shippingpoint}, $form->{shipvia},
+             conv_date($form->{reqdate}), conv_date($form->{tax_point}), $form->{shippingpoint}, $form->{shipvia},
              $restricter->process($form->{notes}), $form->{intnotes},
              $form->{closed} ? 't' : 'f', $form->{delivered} ? "t" : "f",
              conv_i($form->{department_id}), conv_i($form->{language_id}), conv_i($form->{shipto_id}),
@@ -693,7 +693,7 @@ sub retrieve {
   # so if any of these infos is important (or even different) for any item,
   # it will be killed out and then has to be fetched from the item scope query further down
   $query =
-    qq|SELECT dord.cp_id, dord.donumber, dord.ordnumber, dord.transdate, dord.reqdate,
+    qq|SELECT dord.cp_id, dord.donumber, dord.ordnumber, dord.transdate, dord.reqdate, dord.tax_point,
          dord.shippingpoint, dord.shipvia, dord.notes, dord.intnotes,
          e.name AS employee, dord.employee_id, dord.salesman_id,
          dord.${vc}_id, cv.name AS ${vc},
index 92d32e76f9ec8b67a4ec8c279268079b7889b768..c92d267a5c6d76909bd800b36d736d2fada6b998 100644 (file)
@@ -382,10 +382,11 @@ sub create_http_response {
     my $session_cookie_value = $main::auth->get_session_id();
 
     if ($session_cookie_value) {
-      $session_cookie = $cgi->cookie('-name'   => $main::auth->get_session_cookie_name(),
-                                     '-value'  => $session_cookie_value,
-                                     '-path'   => $uri->path,
-                                     '-secure' => $::request->is_https);
+      $session_cookie = $cgi->cookie('-name'    => $main::auth->get_session_cookie_name(),
+                                     '-value'   => $session_cookie_value,
+                                     '-path'    => $uri->path,
+                                     '-expires' => '+' . $::auth->{session_timeout} . 'm',
+                                     '-secure'  => $::request->is_https);
     }
   }
 
@@ -2566,7 +2567,7 @@ sub create_links {
     $query =
       qq|SELECT
            a.cp_id, a.invnumber, a.transdate, a.${table}_id, a.datepaid, a.deliverydate,
-           a.duedate, a.ordnumber, a.taxincluded, (SELECT cu.name FROM currencies cu WHERE cu.id=a.currency_id) AS currency, a.notes,
+           a.duedate, a.tax_point, a.ordnumber, a.taxincluded, (SELECT cu.name FROM currencies cu WHERE cu.id=a.currency_id) AS currency, a.notes,
            a.mtime, a.itime,
            a.intnotes, a.department_id, a.amount AS oldinvtotal,
            a.paid AS oldtotalpaid, a.employee_id, a.gldate, a.type,
@@ -3229,7 +3230,7 @@ sub prepare_for_printing {
 
   # Format dates.
   $self->format_dates($output_dateformat, $output_longdates,
-                      qw(invdate orddate quodate pldate duedate reqdate transdate shippingdate deliverydate validitydate paymentdate datepaid
+                      qw(invdate orddate quodate pldate duedate reqdate transdate tax_point shippingdate deliverydate validitydate paymentdate datepaid
                          transdate_oe deliverydate_oe employee_startdate employee_enddate),
                       grep({ /^(?:datepaid|transdate_oe|reqdate|deliverydate|deliverydate_oe|transdate)_\d+$/ } keys(%{$self})));
 
index 245e5b8008776b4d48fbcdcaf674397bd23697a9..9eb741d8396e84025157d629d30de4482d6a8ab6 100644 (file)
--- a/SL/GL.pm
+++ b/SL/GL.pm
@@ -123,12 +123,12 @@ sub _post_transaction {
   $query =
     qq|UPDATE gl SET
          reference = ?, description = ?, notes = ?,
-         transdate = ?, deliverydate = ?, department_id = ?, taxincluded = ?,
+         transdate = ?, deliverydate = ?, tax_point = ?, department_id = ?, taxincluded = ?,
          storno = ?, storno_id = ?, ob_transaction = ?, cb_transaction = ?
        WHERE id = ?|;
 
   @values = ($form->{reference}, $form->{description}, $form->{notes},
-             conv_date($form->{transdate}), conv_date($form->{deliverydate}), conv_i($form->{department_id}), $form->{taxincluded} ? 't' : 'f',
+             conv_date($form->{transdate}), conv_date($form->{deliverydate}), conv_date($form->{tax_point}), conv_i($form->{department_id}), $form->{taxincluded} ? 't' : 'f',
              $form->{storno} ? 't' : 'f', conv_i($form->{storno_id}), $form->{ob_transaction} ? 't' : 'f', $form->{cb_transaction} ? 't' : 'f',
              conv_i($form->{id}));
   do_query($form, $dbh, $query, @values);
@@ -637,7 +637,7 @@ sub transaction {
 
   if ($form->{id}) {
     $query =
-      qq|SELECT g.reference, g.description, g.notes, g.transdate, g.deliverydate,
+      qq|SELECT g.reference, g.description, g.notes, g.transdate, g.deliverydate, g.tax_point,
            g.storno, g.storno_id,
            g.department_id, d.description AS department,
            e.name AS employee, g.taxincluded, g.gldate,
diff --git a/SL/Helper/Inventory.pm b/SL/Helper/Inventory.pm
new file mode 100644 (file)
index 0000000..da3e5b1
--- /dev/null
@@ -0,0 +1,779 @@
+package SL::Helper::Inventory;
+
+use strict;
+use Carp;
+use DateTime;
+use Exporter qw(import);
+use List::Util qw(min sum);
+use List::UtilsBy qw(sort_by);
+use List::MoreUtils qw(any);
+use POSIX qw(ceil);
+
+use SL::Locale::String qw(t8);
+use SL::MoreCommon qw(listify);
+use SL::DBUtils qw(selectall_hashref_query selectrow_query);
+use SL::DB::TransferType;
+use SL::Helper::Number qw(_format_number _round_number);
+use SL::Helper::Inventory::Allocation;
+use SL::X;
+
+our @EXPORT_OK = qw(get_stock get_onhand allocate allocate_for_assembly produce_assembly check_constraints);
+our %EXPORT_TAGS = (ALL => \@EXPORT_OK);
+
+sub _get_stock_onhand {
+  my (%params) = @_;
+
+  my $onhand_mode = !!$params{onhand};
+
+  my @selects = (
+    'SUM(qty) AS qty',
+    'MIN(EXTRACT(epoch FROM inventory.itime)) AS itime',
+  );
+  my @values;
+  my @where;
+  my @groups;
+
+  if ($params{part}) {
+    my @ids = map { ref $_ ? $_->id : $_ } listify($params{part});
+    push @where, sprintf "parts_id IN (%s)", join ', ', ("?") x @ids;
+    push @values, @ids;
+  }
+
+  if ($params{bin}) {
+    my @ids = map { ref $_ ? $_->id : $_ } listify($params{bin});
+    push @where, sprintf "bin_id IN (%s)", join ', ', ("?") x @ids;
+    push @values, @ids;
+  }
+
+  if ($params{warehouse}) {
+    my @ids = map { ref $_ ? $_->id : $_ } listify($params{warehouse});
+    push @where, sprintf "warehouse.id IN (%s)", join ', ', ("?") x @ids;
+    push @values, @ids;
+  }
+
+  if ($params{chargenumber}) {
+    my @ids = listify($params{chargenumber});
+    push @where, sprintf "chargenumber IN (%s)", join ', ', ("?") x @ids;
+    push @values, @ids;
+  }
+
+  if ($params{date}) {
+    Carp::croak("not DateTime ".$params{date}) unless ref($params{date}) eq 'DateTime';
+    push @where, sprintf "shippingdate <= ?";
+    push @values, $params{date};
+  }
+
+  if (!$params{bestbefore} && $onhand_mode && default_show_bestbefore()) {
+    $params{bestbefore} = DateTime->now_local;
+  }
+
+  if ($params{bestbefore}) {
+    Carp::croak("not DateTime ".$params{date}) unless ref($params{bestbefore}) eq 'DateTime';
+    push @where, sprintf "(bestbefore IS NULL OR bestbefore >= ?)";
+    push @values, $params{bestbefore};
+  }
+
+  # by
+  my %allowed_by = (
+    part          => [ qw(parts_id) ],
+    bin           => [ qw(bin_id inventory.warehouse_id)],
+    warehouse     => [ qw(inventory.warehouse_id) ],
+    chargenumber  => [ qw(chargenumber) ],
+    bestbefore    => [ qw(bestbefore) ],
+    for_allocate  => [ qw(parts_id bin_id inventory.warehouse_id chargenumber bestbefore) ],
+  );
+
+  if ($params{by}) {
+    for (listify($params{by})) {
+      my $selects = $allowed_by{$_} or Carp::croak("unknown option for by: $_");
+      push @selects, @$selects;
+      push @groups,  @$selects;
+    }
+  }
+
+  my $select   = join ',', @selects;
+  my $where    = @where  ? 'WHERE ' . join ' AND ', @where : '';
+  my $group_by = @groups ? 'GROUP BY ' . join ', ', @groups : '';
+
+  my $query = <<"";
+    SELECT $select FROM inventory
+    LEFT JOIN bin ON bin_id = bin.id
+    LEFT JOIN warehouse ON bin.warehouse_id = warehouse.id
+    $where
+    $group_by
+
+  if ($onhand_mode) {
+    $query .= ' HAVING SUM(qty) > 0';
+  }
+
+  my $results = selectall_hashref_query($::form, SL::DB->client->dbh, $query, @values);
+
+  my %with_objects = (
+    part         => 'SL::DB::Manager::Part',
+    bin          => 'SL::DB::Manager::Bin',
+    warehouse    => 'SL::DB::Manager::Warehouse',
+  );
+
+  my %slots = (
+    part      =>  'parts_id',
+    bin       =>  'bin_id',
+    warehouse =>  'warehouse_id',
+  );
+
+  if ($params{by} && $params{with_objects}) {
+    for my $with_object (listify($params{with_objects})) {
+      Carp::croak("unknown with_object $with_object") if !exists $with_objects{$with_object};
+
+      my $manager = $with_objects{$with_object};
+      my $slot = $slots{$with_object};
+      next if !(my @ids = map { $_->{$slot} } @$results);
+      my $objects = $manager->get_all(query => [ id => \@ids ]);
+      my %objects_by_id = map { $_->id => $_ } @$objects;
+
+      $_->{$with_object} = $objects_by_id{$_->{$slot}} for @$results;
+    }
+  }
+
+  if ($params{by}) {
+    return $results;
+  } else {
+    return $results->[0]{qty};
+  }
+}
+
+sub get_stock {
+  _get_stock_onhand(@_, onhand => 0);
+}
+
+sub get_onhand {
+  _get_stock_onhand(@_, onhand => 1);
+}
+
+sub allocate {
+  my (%params) = @_;
+
+  croak('allocate needs a part') unless $params{part};
+  croak('allocate needs a qty')  unless $params{qty};
+
+  my $part = $params{part};
+  my $qty  = $params{qty};
+
+  return () if $qty <= 0;
+
+  my $results = get_stock(part => $part, by => 'for_allocate');
+  my %bin_whitelist = map { (ref $_ ? $_->id : $_) => 1 } grep defined, listify($params{bin});
+  my %wh_whitelist  = map { (ref $_ ? $_->id : $_) => 1 } grep defined, listify($params{warehouse});
+  my %chargenumbers = map { (ref $_ ? $_->id : $_) => 1 } grep defined, listify($params{chargenumber});
+
+  # sort results so that chargenumbers are matched first, then wanted bins, then wanted warehouses
+  my @sorted_results = sort {
+       exists $chargenumbers{$b->{chargenumber}}  <=> exists $chargenumbers{$a->{chargenumber}} # then prefer wanted chargenumbers
+    || exists $bin_whitelist{$b->{bin_id}}        <=> exists $bin_whitelist{$a->{bin_id}}       # then prefer wanted bins
+    || exists $wh_whitelist{$b->{warehouse_id}}   <=> exists $wh_whitelist{$a->{warehouse_id}}  # then prefer wanted bins
+    || $a->{itime}                                <=> $b->{itime}                               # and finally prefer earlier charges
+  } @$results;
+  my @allocations;
+  my $rest_qty = $qty;
+
+  for my $chunk (@sorted_results) {
+    my $qty = min($chunk->{qty}, $rest_qty);
+
+    # since allocate operates on stock, this also ensures that no negative stock results are used
+    if ($qty > 0) {
+      push @allocations, SL::Helper::Inventory::Allocation->new(
+        parts_id          => $chunk->{parts_id},
+        qty               => $qty,
+        comment           => $params{comment},
+        bin_id            => $chunk->{bin_id},
+        warehouse_id      => $chunk->{warehouse_id},
+        chargenumber      => $chunk->{chargenumber},
+        bestbefore        => $chunk->{bestbefore},
+        for_object_id     => undef,
+      );
+      $rest_qty -=  _round_number($qty, 5);
+    }
+    $rest_qty = _round_number($rest_qty, 5);
+    last if $rest_qty == 0;
+  }
+  if ($rest_qty > 0) {
+    die SL::X::Inventory::Allocation->new(
+      error => 'not enough to allocate',
+      msg => t8("can not allocate #1 units of #2, missing #3 units", _format_number($qty), $part->displayable_name, _format_number($rest_qty)),
+    );
+  } else {
+    if ($params{constraints}) {
+      check_constraints($params{constraints},\@allocations);
+    }
+    return @allocations;
+  }
+}
+
+sub allocate_for_assembly {
+  my (%params) = @_;
+
+  my $part = $params{part} or Carp::croak('allocate needs a part');
+  my $qty  = $params{qty}  or Carp::croak('allocate needs a qty');
+
+  Carp::croak('not an assembly') unless $part->is_assembly;
+
+  my %parts_to_allocate;
+
+  for my $assembly ($part->assemblies) {
+    $parts_to_allocate{ $assembly->part->id } //= 0;
+    $parts_to_allocate{ $assembly->part->id } += $assembly->qty * $qty;
+  }
+
+  my @allocations;
+
+  for my $part_id (keys %parts_to_allocate) {
+    my $part = SL::DB::Part->load_cached($part_id);
+    push @allocations, allocate(%params, part => $part, qty => $parts_to_allocate{$part_id});
+  }
+
+  @allocations;
+}
+
+sub check_constraints {
+  my ($constraints, $allocations) = @_;
+  if ('CODE' eq ref $constraints) {
+    if (!$constraints->(@$allocations)) {
+      die SL::X::Inventory::Allocation->new(
+        error => 'allocation constraints failure',
+        msg => t8("Allocations didn't pass constraints"),
+      );
+    }
+  } else {
+    croak 'constraints needs to be a hashref' unless 'HASH' eq ref $constraints;
+
+    my %supported_constraints = (
+      bin_id       => 'bin_id',
+      warehouse_id => 'warehouse_id',
+      chargenumber => 'chargenumber',
+    );
+
+    for (keys %$constraints ) {
+      croak "unsupported constraint '$_'" unless $supported_constraints{$_};
+      next unless defined $constraints->{$_};
+
+      my %whitelist = map { (ref $_ ? $_->id : $_) => 1 } listify($constraints->{$_});
+      my $accessor = $supported_constraints{$_};
+
+      if (any { !$whitelist{$_->$accessor} } @$allocations) {
+        my %error_constraints = (
+          bin_id         => t8('Bins'),
+          warehouse_id   => t8('Warehouses'),
+          chargenumber   => t8('Chargenumbers'),
+        );
+        my @allocs = grep { $whitelist{$_->$accessor} } @$allocations;
+        my $needed = sum map { $_->qty } grep { !$whitelist{$_->$accessor} } @$allocations;
+        my $err    = t8("Cannot allocate parts.");
+        $err      .= ' '.t8('part \'#\'1 in bin \'#2\' only with qty #3 (need additional #4) and chargenumber \'#5\'.',
+              SL::DB::Part->load_cached($_->parts_id)->description,
+              SL::DB::Bin->load_cached($_->bin_id)->full_description,
+              _format_number($_->qty), _format_number($needed), $_->chargenumber ? $_->chargenumber : '--') for @allocs;
+        die SL::X::Inventory::Allocation->new(
+          error => 'allocation constraints failure',
+          msg   => $err,
+        );
+      }
+    }
+  }
+}
+
+sub produce_assembly {
+  my (%params) = @_;
+
+  my $part = $params{part} or Carp::croak('produce_assembly needs a part');
+  my $qty  = $params{qty}  or Carp::croak('produce_assembly needs a qty');
+
+  my $allocations = $params{allocations};
+  if ($params{auto_allocate}) {
+    Carp::croak("produce_assembly: can't have both allocations and auto_allocate") if $params{allocations};
+    $allocations = [ allocate_for_assembly(part => $part, qty => $qty) ];
+  } else {
+    Carp::croak("produce_assembly: need allocations or auto_allocate to produce something") if !$params{allocations};
+    $allocations = $params{allocations};
+  }
+
+  my $bin          = $params{bin} or Carp::croak("need target bin");
+  my $chargenumber = $params{chargenumber};
+  my $bestbefore   = $params{bestbefore};
+  my $for_object_id = $params{for_object_id};
+  my $comment      = $params{comment} // '';
+
+  my $invoice               = $params{invoice};
+  my $project               = $params{project};
+
+  my $shippingdate = $params{shippingsdate} // DateTime->now_local;
+
+  my $trans_id              = $params{trans_id};
+  ($trans_id) = selectrow_query($::form, SL::DB->client->dbh, qq|SELECT nextval('id')| ) unless $trans_id;
+
+  my $trans_type_out = SL::DB::Manager::TransferType->find_by(direction => 'out', description => 'used');
+  my $trans_type_in  = SL::DB::Manager::TransferType->find_by(direction => 'in', description => 'assembled');
+
+  # check whether allocations are sane
+  if (!$params{no_check_allocations} && !$params{auto_allocate}) {
+    my %allocations_by_part = map { $_->parts_id  => $_->qty } @$allocations;
+    for my $assembly ($part->assemblies) {
+      $allocations_by_part{ $assembly->parts_id } -= $assembly->qty * $qty;
+    }
+
+    die "allocations are insufficient for production" if any { $_ < 0 } values %allocations_by_part;
+  }
+
+  my @transfers;
+  for my $allocation (@$allocations) {
+    my $oe_id = delete $allocation->{for_object_id};
+    push @transfers, $allocation->transfer_object(
+      trans_id     => $trans_id,
+      qty          => -$allocation->qty,
+      trans_type   => $trans_type_out,
+      shippingdate => $shippingdate,
+      employee     => SL::DB::Manager::Employee->current,
+    );
+  }
+
+  push @transfers, SL::DB::Inventory->new(
+    trans_id          => $trans_id,
+    trans_type        => $trans_type_in,
+    part              => $part,
+    qty               => $qty,
+    bin               => $bin,
+    warehouse         => $bin->warehouse_id,
+    chargenumber      => $chargenumber,
+    bestbefore        => $bestbefore,
+    shippingdate      => $shippingdate,
+    project           => $project,
+    invoice           => $invoice,
+    comment           => $comment,
+    employee          => SL::DB::Manager::Employee->current,
+    oe_id             => $for_object_id,
+  );
+
+  SL::DB->client->with_transaction(sub {
+    $_->save for @transfers;
+    1;
+  }) or do {
+    die SL::DB->client->error;
+  };
+
+  @transfers;
+}
+
+sub default_show_bestbefore {
+  $::instance_conf->get_show_bestbefore
+}
+
+1;
+
+=encoding utf-8
+
+=head1 NAME
+
+SL::WH - Warehouse and Inventory API
+
+=head1 SYNOPSIS
+
+  # See description for an intro to the concepts used here.
+
+  use SL::Helper::Inventory qw(:ALL);
+
+  # stock, get "what's there" for a part with various conditions:
+  my $qty = get_stock(part => $part);                              # how much is on stock?
+  my $qty = get_stock(part => $part, date => $date);               # how much was on stock at a specific time?
+  my $qty = get_stock(part => $part, bin => $bin);                 # how much is on stock in a specific bin?
+  my $qty = get_stock(part => $part, warehouse => $warehouse);     # how much is on stock in a specific warehouse?
+  my $qty = get_stock(part => $part, chargenumber => $chargenumber); # how much is on stock of a specific chargenumber?
+
+  # onhand, get "what's available" for a part with various conditions:
+  my $qty = get_onhand(part => $part);                              # how much is available?
+  my $qty = get_onhand(part => $part, date => $date);               # how much was available at a specific time?
+  my $qty = get_onhand(part => $part, bin => $bin);                 # how much is available in a specific bin?
+  my $qty = get_onhand(part => $part, warehouse => $warehouse);     # how much is available in a specific warehouse?
+  my $qty = get_onhand(part => $part, chargenumber => $chargenumber); # how much is availbale of a specific chargenumber?
+
+  # onhand batch mode:
+  my $data = get_onhand(
+    warehouse    => $warehouse,
+    by           => [ qw(bin part chargenumber) ],
+    with_objects => [ qw(bin part) ],
+  );
+
+  # allocate:
+  my @allocations = allocate(
+    part         => $part,          # part_id works too
+    qty          => $qty,           # must be positive
+    chargenumber => $chargenumber,  # optional, may be arrayref. if provided these charges will be used first
+    bestbefore   => $datetime,      # optional, defaults to today. items with bestbefore prior to that date wont be used
+    bin          => $bin,           # optional, may be arrayref. if provided
+  );
+
+  # shortcut to allocate all that is needed for producing an assembly, will use chargenumbers as appropriate
+  my @allocations = allocate_for_assembly(
+    part         => $assembly,      # part_id works too
+    qty          => $qty,           # must be positive
+  );
+
+  # create allocation manually, bypassing checks. all of these need to be passed, even undefs
+  my $allocation = SL::Helper::Inventory::Allocation->new(
+    part_id           => $part->id,
+    qty               => 15,
+    bin_id            => $bin_obj->id,
+    warehouse_id      => $bin_obj->warehouse_id,
+    chargenumber      => '1823772365',
+    bestbefore        => undef,
+    for_object_id     => $order->id,
+  );
+
+  # produce_assembly:
+  produce_assembly(
+    part         => $part,           # target assembly
+    qty          => $qty,            # qty
+    allocations  => \@allocations,   # allocations to use. alternatively use "auto_allocate => 1,"
+
+    # where to put it
+    bin          => $bin,           # needed unless a global standard target is configured
+    chargenumber => $chargenumber,  # optional
+    bestbefore   => $datetime,      # optional
+    comment      => $comment,       # optional
+  );
+
+=head1 DESCRIPTION
+
+New functions for the warehouse and inventory api.
+
+The WH api currently has three large shortcomings: It is very hard to just get
+the current stock for an item, it's extremely complicated to use it to produce
+assemblies while ensuring that no stock ends up negative, and it's very hard to
+use it to get an overview over the actual contents of the inventory.
+
+The first problem has spawned several dozen small functions in the program that
+try to implement that, and those usually miss some details. They may ignore
+bestbefore times, comments, ignore negative quantities etc.
+
+To get this cleaned up a bit this code introduces two concepts: stock and onhand.
+
+=over 4
+
+=item * Stock is defined as the actual contents of the inventory, everything that is
+there.
+
+=item * Onhand is what is available, which means things that are stocked,
+not expired and not in any other way reserved for other uses.
+
+=back
+
+The two new functions C<get_stock> and C<get_onhand> encapsulate these principles and
+allow simple access with some optional filters for chargenumbers or warehouses.
+Both of them have a batch mode that can be used to get these information to
+supplement simple reports.
+
+To address the safe assembly creation a new function has been added.
+C<allocate> will try to find the requested quantity of a part in the inventory
+and will return allocations of it which can then be used to create the
+assembly. Allocation will happen with the C<onhand> semantics defined above,
+meaning that by default no expired goods will be used. The caller can supply
+hints of what shold be used and in those cases chargenumbers will be used up as
+much as possible first. C<allocate> will always try to fulfil the request even
+beyond those. Should the required amount not be stocked, allocate will throw an
+exception.
+
+C<produce_assembly> has been rewritten to only accept parameters about the
+target of the production, and requires allocations to complete the request. The
+allocations can be supplied manually, or can be generated automatically.
+C<produce_assembly> will check whether enough allocations are given to create
+the assembly, but will not check whether the allocations are backed. If the
+allocations are not sufficient or if the auto-allocation fails an exception
+is returned. If you need to produce something that is not in the inventory, you
+can bypass those checks by creating the allocations yourself (see
+L</"ALLOCATION DATA STRUCTURE">).
+
+Note: this is only intended to cover the scenarios described above. For other cases:
+
+=over 4
+
+=item *
+
+If you need actual inventory objects because of record links or something like
+that load them directly. And strongly consider redesigning that, because it's
+really fragile.
+
+=item *
+
+You need weight or accounting information you're on your own. The inventory api
+only concerns itself with the raw quantities.
+
+=item *
+
+If you need the first stock date of parts, or anything related to a specific
+transfer type or direction, this is not covered yet.
+
+=back
+
+=head1 FUNCTIONS
+
+=over 4
+
+=item * get_stock PARAMS
+
+Returns for single parts how much actually exists in the inventory.
+
+Options:
+
+=over 4
+
+=item * part
+
+The part. Must be present without C<by>. May be arrayref with C<by>. Can be object or id.
+
+=item * bin
+
+If given, will only return stock on these bins. Optional. May be array, May be object or id.
+
+=item * warehouse
+
+If given, will only return stock on these warehouses. Optional. May be array, May be object or id.
+
+=item * date
+
+If given, will return stock as it were on this timestamp. Optional. Must be L<DateTime> object.
+
+=item * chargenumber
+
+If given, will only show stock with this chargenumber. Optional. May be array.
+
+=item * by
+
+See L</"STOCK/ONHAND REPORT MODE">
+
+=item * with_objects
+
+See L</"STOCK/ONHAND REPORT MODE">
+
+=back
+
+Will return a single qty normally, see L</"STOCK/ONHAND REPORT MODE"> for batch
+mode when C<by> is given.
+
+=item * get_onhand PARAMS
+
+Returns for single parts how much is available in the inventory. That excludes
+stock with expired bestbefore.
+
+It takes the same options as L</get_stock>.
+
+=over 4
+
+=item * bestbefore
+
+If given, will only return stock with a bestbefore at or after the given date.
+Optional. Must be L<DateTime> object.
+
+=back
+
+=item * allocate PARAMS
+
+Accepted parameters:
+
+=over 4
+
+=item * part
+
+=item * qty
+
+=item * bin
+
+Bin object. Optional.
+
+=item * warehouse
+
+Warehouse object. Optional.
+
+=item * chargenumber
+
+Optional.
+
+=item * bestbefore
+
+Datetime. Optional.
+
+=back
+
+Tries to allocate the required quantity using what is currently onhand. If
+given any of C<bin>, C<warehouse>, C<chargenumber>
+
+=item * allocate_for_assembly PARAMS
+
+Shortcut to allocate everything for an assembly. Takes the same arguments. Will
+compute the required amount for each assembly part and allocate all of them.
+
+=item * produce_assembly
+
+
+=back
+
+=head1 STOCK/ONHAND REPORT MODE
+
+If the special option C<by> is given with an arrayref, the result will instead
+be an arrayref of partitioned stocks by those fields. Valid partitions are:
+
+=over 4
+
+=item * part
+
+If this is given, part is optional in the parameters
+
+=item * bin
+
+=item * warehouse
+
+=item * chargenumber
+
+=item * bestbefore
+
+=back
+
+Note: If you want to use the returned data to create allocations you I<need> to
+enable all of these. To make this easier a special shortcut exists
+
+In this mode, C<with_objects> can be used to load C<warehouse>, C<bin>,
+C<parts>  objects in one go, just like with Rose. They
+need to be present in C<by> before that though.
+
+=head1 ALLOCATION ALGORITHM
+
+When calling allocate, the current onhand (== available stock) of the item will
+be used to decide which bins/chargenumbers/bestbefore can be used.
+
+In general allocate will try to make the request happen, and will use the
+provided charges up first, and then tap everything else. If you need to only
+I<exactly> use the provided charges, you'll need to craft the allocations
+yourself. See L</"ALLOCATION DATA STRUCTURE"> for that.
+
+If C<chargenumber> is given, those will be used up next.
+
+After that normal quantities will be used.
+
+These are tiebreakers and expected to rarely matter in reality. If you need
+finegrained control over which allocation is used, you may want to get the
+onhands yourself and select the appropriate ones.
+
+Only quantities with C<bestbefore> unset or after the given date will be
+considered. If more than one charge is eligible, the earlier C<bestbefore>
+will be used.
+
+Allocations do NOT have an internal memory and can't react to other allocations
+of the same part earlier. Never double allocate the same part within a
+transaction.
+
+=head1 ALLOCATION DATA STRUCTURE
+
+Allocations are instances of the helper class C<SL::Helper::Inventory::Allocation>. They require
+each of the following attributes to be set at creation time:
+
+=over 4
+
+=item * parts_id
+
+=item * qty
+
+=item * bin_id
+
+=item * warehouse_id
+
+=item * chargenumber
+
+=item * bestbefore
+
+=item * for_object_id
+
+If set the allocations will be marked as allocated for the given object.
+If these allocations are later used to produce an assembly, the resulting
+consuming transactions will be marked as belonging to the given object.
+The object may be an order, productionorder or other objects
+
+=back
+
+C<chargenumber>, C<bestbefore> and C<for_object_id> and C<comment> may be
+C<undef> (but must still be present at creation time). Instances are considered
+immutable.
+
+Allocations also provide the method C<transfer_object> which will create a new
+C<SL::DB::Inventory> bject with all the playload.
+
+=head1 CONSTRAINTS
+
+  # whitelist constraints
+  ->allocate(
+    ...
+    constraints => {
+      bin_id       => \@allowed_bins,
+      chargenumber => \@allowed_chargenumbers,
+    }
+  );
+
+  # custom constraints
+  ->allocate(
+    constraints => sub {
+      # only allow chargenumbers with specific format
+      all { $_->chargenumber =~ /^ C \d{8} - \a{d2} $/x } @_
+
+      &&
+      # and must all have a bestbefore date
+      all { $_->bestbefore } @_;
+    }
+  )
+
+C<allocation> is "best effort" in nature. It will take the C<bin>,
+C<chargenumber> etc hints from the parameters, but will try it's bvest to
+fulfil the request anyway and only bail out if it is absolutely not possible.
+
+Sometimes you need to restrict allocations though. For this you can pass
+additional constraints to C<allocate>. A constraint serves as a whitelist.
+Every allocation must fulfil every constraint by having that attribute be one
+of the given values.
+
+In case even that is not enough, you may supply a custom check by passing a
+function that will be given the allocation objects.
+
+Note that both whitelists and constraints do not influence the order of
+allocations, which is done purely from the initial parameters. They only serve
+to reject allocations made in good faith which do fulfil required assertions.
+
+=head1 ERROR HANDLING
+
+C<allocate> and C<produce_assembly> will throw exceptions if the request can
+not be completed. The usual reason will be insufficient onhand to allocate, or
+insufficient allocations to process the request.
+
+=head1 KNOWN PROBLEMS
+
+  * It's not currently possible to identify allocations between requests, for
+    example for presenting the user possible allocations and then actually using
+    them on the next request.
+  * It's not currently possible to give C<allocate> prior constraints.
+    Currently all constraints are treated as hints (and will be preferred) but
+    the internal ordering of the hints is fixed and more complex preferentials
+    are not supported.
+  * bestbefore handling is untested
+  * interaction with config option "transfer_default_ignore_onhand" is
+    currently undefined (and implicitly ignores it)
+
+=head1 TODO
+
+  * define and describe error classes
+  * define wrapper classes for stock/onhand batch mode return values
+  * handle extra arguments in produce: shippingdate, project
+  * document no_ check
+  * tests
+
+=head1 BUGS
+
+None yet :)
+
+=head1 AUTHOR
+
+Sven Schöling E<lt>sven.schoeling@googlemail.comE<gt>
+
+=cut
diff --git a/SL/Helper/Inventory/Allocation.pm b/SL/Helper/Inventory/Allocation.pm
new file mode 100644 (file)
index 0000000..e406bf4
--- /dev/null
@@ -0,0 +1,65 @@
+package SL::Helper::Inventory::Allocation;
+
+use strict;
+
+my @attributes = qw(parts_id qty bin_id warehouse_id chargenumber bestbefore comment for_object_id);
+my %attributes = map { $_ => 1 } @attributes;
+my %mapped_attributes = (
+  for_object_id => 'oe_id',
+);
+
+for my $name (@attributes) {
+  no strict 'refs';
+  *{"SL::Helper::Inventory::Allocation::$name"} = sub { $_[0]{$name} };
+}
+
+sub new {
+  my ($class, %params) = @_;
+
+  Carp::croak("missing attribute $_") for grep { !exists $params{$_}     } @attributes;
+  Carp::croak("unknown attribute $_") for grep { !exists $attributes{$_} } keys %params;
+  Carp::croak("$_ must be set")       for grep { !$params{$_} } qw(parts_id qty bin_id);
+  Carp::croak("$_ must be positive")  for grep { !($params{$_} > 0) } qw(parts_id qty bin_id);
+
+  bless { %params }, $class;
+}
+
+sub transfer_object {
+  my ($self, %params) = @_;
+
+  SL::DB::Inventory->new(
+    (map {
+      my $attr = $mapped_attributes{$_} // $_;
+      $attr => $self->{$attr}
+    } @attributes),
+    %params,
+  );
+}
+
+1;
+
+=encoding utf-8
+
+=head1 NAME
+
+SL::Helper::Inventory::Allocation - Inventory API allocation data structure
+
+=head1 SYNOPSIS
+
+  # all of these need to be present
+  my $allocation = SL::Helper::Inventory::Allocation->new(
+    part_id           => $part->id,
+    qty               => 15,
+    bin_id            => $bin_obj->id,
+    warehouse_id      => $bin_obj->warehouse_id,
+    chargenumber      => '1823772365',           # can be undef
+    bestbefore        => undef,                  # can be undef
+    for_object_id     => $order->id,             # can be undef
+  );
+
+
+=head1 SEE ALSO
+
+The full documentation can be found in L<SL::Helper::Inventory>
+
+=cut
index 868fb741748649aa43dbf15ed6c1666e13a46a0e..ca03e514456595b96cb3c0da845314cb1279a77f 100644 (file)
--- a/SL/IC.pm
+++ b/SL/IC.pm
@@ -739,37 +739,14 @@ sub retrieve_accounts {
 
   # transdate madness.
   my $transdate = "";
-  if ($form->{type} eq "invoice" or $form->{type} eq "credit_note") {
+  if (($form->{type} eq "invoice") or ($form->{type} eq "credit_note") or ($form->{script} eq 'ir.pl')) {
     # use deliverydate for sales and purchase invoice, if it exists
     # also use deliverydate for credit notes
-    if (!$form->{deliverydate}) {
-      $transdate = $form->{invdate};
-    } else {
-      $transdate = $form->{deliverydate};
-    }
-  } elsif ($form->{script} eq 'ir.pl') {
-    # when a purchase invoice is opened from the report of purchase invoices
-    # $form->{type} isn't set, but $form->{script} is, not sure why this is or
-    # whether this distinction matters in some other scenario. Otherwise one
-    # could probably take out this elsif and add a
-    # " or $form->{script} eq 'ir.pl' "
-    # to the above if-statement
-    if (!$form->{deliverydate}) {
-      $transdate = $form->{invdate};
-    } else {
-      $transdate = $form->{deliverydate};
-    }
-  } elsif (($form->{type} eq "credit_note") and $form->{deliverydate}) {
-    # if credit_note has a deliverydate, use this instead of invdate
-    # useful for credit_notes of invoices from an old period with different tax
-    # if there is no deliverydate then invdate is used, old default (see next elsif)
-    # Falls hier der Stichtag für Steuern anders bestimmt wird,
-    # entsprechend auch bei Taxkeys.pm anpassen
-    $transdate = $form->{deliverydate};
-  } elsif (($form->{type} eq "credit_note") || ($form->{script} eq 'ir.pl')) {
-    $transdate = $form->{invdate};
+    $transdate = $form->{tax_point} || $form->{deliverydate} || $form->{invdate};
   } else {
-    $transdate = $form->{transdate};
+    my $deliverydate;
+    $deliverydate = $form->{reqdate} if any { $_ eq $form->{type} } qw(sales_order request_quotation purchase_order);
+    $transdate = $form->{tax_point} || $deliverydate || $form->{transdate};
   }
 
   if ($transdate eq "") {
index 5c768cf8af5fe3a36e95bfe12f96f2b4eeca8442..2663ad676daf24447a7d4f402d157a310f8d8cfd 100644 (file)
--- a/SL/IR.pm
+++ b/SL/IR.pm
@@ -549,7 +549,7 @@ SQL
     if ($form->{currency} ne $defaultcurrency) && !$exchangerate;
 
 # record acc_trans transactions
-  my $taxdate = $form->{deliverydate} ? $form->{deliverydate} : $form->{invdate};
+  my $taxdate = $form->{tax_point} || $form->{deliverydate} || $form->{invdate};
   foreach my $trans_id (keys %{ $form->{amount} }) {
     foreach my $accno (keys %{ $form->{amount}{$trans_id} }) {
       $form->{amount}{$trans_id}{$accno} = $form->round_amount($form->{amount}{$trans_id}{$accno}, 2);
@@ -735,7 +735,7 @@ SQL
                 orddate      = ?, quodate     = ?, vendor_id     = ?, amount       = ?,
                 netamount    = ?, paid        = ?, duedate       = ?, deliverydate = ?,
                 invoice      = ?, taxzone_id  = ?, notes         = ?, taxincluded  = ?,
-                intnotes     = ?, storno_id   = ?, storno        = ?,
+                intnotes     = ?, storno_id   = ?, storno        = ?, tax_point    = ?,
                 cp_id        = ?, employee_id = ?, department_id = ?, delivery_term_id = ?,
                 payment_id   = ?,
                 currency_id = (SELECT id FROM currencies WHERE name = ?),
@@ -746,7 +746,7 @@ SQL
       conv_date($form->{orddate}), conv_date($form->{quodate}),     conv_i($form->{vendor_id}),               $amount,
                 $netamount,                  $form->{paid},        conv_date($form->{duedate}),     conv_date($form->{deliverydate}),
             '1',                             $taxzone_id, $restricter->process($form->{notes}),               $form->{taxincluded} ? 't' : 'f',
-                $form->{intnotes},           conv_i($form->{storno_id}),     $form->{storno}      ? 't' : 'f',
+                $form->{intnotes},           conv_i($form->{storno_id}),     $form->{storno}      ? 't' : 'f', conv_date($form->{tax_point}),
          conv_i($form->{cp_id}),      conv_i($form->{employee_id}), conv_i($form->{department_id}), conv_i($form->{delivery_term_id}),
          conv_i($form->{payment_id}),
                 $form->{"currency"},
@@ -1002,7 +1002,7 @@ sub retrieve_invoice {
 
   # retrieve invoice
   $query = qq|SELECT cp_id, invnumber, transdate AS invdate, duedate,
-                orddate, quodate, deliverydate, globalproject_id,
+                orddate, quodate, deliverydate, tax_point, globalproject_id,
                 ordnumber, quonumber, paid, taxincluded, notes, taxzone_id, storno, gldate,
                 mtime, itime,
                 intnotes, (SELECT cu.name FROM currencies cu WHERE cu.id=ap.currency_id) AS currency, direct_debit,
@@ -1022,7 +1022,7 @@ sub retrieve_invoice {
   delete $ref->{id};
   map { $form->{$_} = $ref->{$_} } keys %$ref;
 
-  my $transdate  = $form->{invdate} ? $dbh->quote($form->{invdate}) : "current_date";
+  my $transdate  = $form->{tax_point} ? $dbh->quote($form->{tax_point}) :$form->{invdate} ? $dbh->quote($form->{invdate}) : "current_date";
 
   my $taxzone_id = $form->{taxzone_id} * 1;
   $taxzone_id = SL::DB::Manager::TaxZone->get_default->id unless SL::DB::Manager::TaxZone->find_by(id => $taxzone_id);
index e9e9144d41523beb09389987cba01e9118988649..5fe66cf47465c924d6329d7e9fc962a6899724b6 100644 (file)
--- a/SL/IS.pm
+++ b/SL/IS.pm
@@ -1037,7 +1037,7 @@ SQL
 
   $project_id = conv_i($form->{"globalproject_id"});
   # entsprechend auch beim Bestimmen des Steuerschlüssels in Taxkey.pm berücksichtigen
-  my $taxdate = $form->{deliverydate} ? $form->{deliverydate} : $form->{invdate};
+  my $taxdate = $form->{tax_point} ||$form->{deliverydate} || $form->{invdate};
 
   foreach my $trans_id (keys %{ $form->{amount_cogs} }) {
     foreach my $accno (keys %{ $form->{amount_cogs}{$trans_id} }) {
@@ -1312,7 +1312,7 @@ SQL
 
   $query = qq|UPDATE ar set
                 invnumber   = ?, ordnumber     = ?, quonumber     = ?, cusordnumber  = ?,
-                transdate   = ?, orddate       = ?, quodate       = ?, customer_id   = ?,
+                transdate   = ?, orddate       = ?, quodate       = ?, tax_point     = ?, customer_id   = ?,
                 amount      = ?, netamount     = ?, paid          = ?,
                 duedate     = ?, deliverydate  = ?, invoice       = ?, shippingpoint = ?,
                 shipvia     = ?,                    notes         = ?, intnotes      = ?,
@@ -1327,7 +1327,7 @@ SQL
                 delivery_term_id = ?
               WHERE id = ?|;
   @values = (          $form->{"invnumber"},           $form->{"ordnumber"},             $form->{"quonumber"},          $form->{"cusordnumber"},
-             conv_date($form->{"invdate"}),  conv_date($form->{"orddate"}),    conv_date($form->{"quodate"}),    conv_i($form->{"customer_id"}),
+             conv_date($form->{"invdate"}),  conv_date($form->{"orddate"}),    conv_date($form->{"quodate"}), conv_date($form->{tax_point}), conv_i($form->{"customer_id"}),
                        $amount,                        $netamount,                       $form->{"paid"},
              conv_date($form->{"duedate"}),  conv_date($form->{"deliverydate"}),    '1',                                $form->{"shippingpoint"},
                        $form->{"shipvia"},                                $restricter->process($form->{"notes"}),       $form->{"intnotes"},
@@ -2012,7 +2012,7 @@ sub _retrieve_invoice {
       qq|SELECT
            a.invnumber, a.ordnumber, a.quonumber, a.cusordnumber,
            a.orddate, a.quodate, a.globalproject_id,
-           a.transdate AS invdate, a.deliverydate, a.paid, a.storno, a.storno_id, a.gldate,
+           a.transdate AS invdate, a.deliverydate, a.tax_point, a.paid, a.storno, a.storno_id, a.gldate,
            a.shippingpoint, a.shipvia, a.notes, a.intnotes, a.taxzone_id,
            a.duedate, a.taxincluded, (SELECT cu.name FROM currencies cu WHERE cu.id=a.currency_id) AS currency, a.shipto_id, a.cp_id,
            a.employee_id, a.salesman_id, a.payment_id,
@@ -2055,7 +2055,8 @@ sub _retrieve_invoice {
     $sth->finish;
     map { $form->{$_} =~ s/ +$//g } qw(printed emailed queued);
 
-    my $transdate = $form->{deliverydate} ? $dbh->quote($form->{deliverydate})
+    my $transdate = $form->{tax_point}    ? $dbh->quote($form->{tax_point})
+                  : $form->{deliverydate} ? $dbh->quote($form->{deliverydate})
                   : $form->{invdate}      ? $dbh->quote($form->{invdate})
                   :                         "current_date";
 
index 0503dcb6c8a6f33640dc3c55b125597e58e83cff..a1e19767c7599a37b82f03d5817d3fefac9d95c9 100644 (file)
@@ -19,8 +19,9 @@ use constant FILE_TARGET   => 0;
 use constant STDERR_TARGET => 1;
 
 use Data::Dumper;
+use List::MoreUtils qw(all);
 use POSIX qw(strftime getpid);
-use Scalar::Util qw(blessed refaddr weaken);
+use Scalar::Util qw(blessed refaddr weaken looks_like_number);
 use Time::HiRes qw(gettimeofday tv_interval);
 use SL::Request ();
 use SL::YAML;
@@ -231,8 +232,14 @@ sub dump_sql_result {
     map { $column_lengths{$_} = length $row->{$_} if (length $row->{$_} > $column_lengths{$_}) } keys %{ $row };
   }
 
+  my %alignment;
+  foreach my $column (keys %column_lengths) {
+    my $all_look_like_number = all { (($_->{$column} // '') eq '') || looks_like_number($_->{$column}) } @{ $results };
+    $alignment{$column}      = $all_look_like_number ? '' : '-';
+  }
+
   my @sorted_names = sort keys %column_lengths;
-  my $format       = join '|', map { '%' . $column_lengths{$_} . 's' } @sorted_names;
+  my $format       = join '|', map { '%'  . $alignment{$_} . $column_lengths{$_} . 's' } @sorted_names;
 
   $prefix .= ' ' if $prefix;
 
index 974a1a1bdcedc935e7cdc6ad82116f348a610a2f..759f962651a6aa215b24b75df6b613bd7e7c73d5 100644 (file)
--- a/SL/OE.pm
+++ b/SL/OE.pm
@@ -743,7 +743,7 @@ SQL
   $query =
     qq|UPDATE oe SET
          ordnumber = ?, quonumber = ?, cusordnumber = ?, transdate = ?, vendor_id = ?,
-         customer_id = ?, amount = ?, netamount = ?, reqdate = ?, taxincluded = ?,
+         customer_id = ?, amount = ?, netamount = ?, reqdate = ?, tax_point = ?, taxincluded = ?,
          shippingpoint = ?, shipvia = ?, notes = ?, intnotes = ?, currency_id = (SELECT id FROM currencies WHERE name=?), closed = ?,
          delivered = ?, proforma = ?, quotation = ?, department_id = ?, language_id = ?,
          taxzone_id = ?, shipto_id = ?, payment_id = ?, delivery_vendor_id = ?, delivery_customer_id = ?,delivery_term_id = ?,
@@ -754,7 +754,7 @@ SQL
   @values = ($form->{ordnumber} || '', $form->{quonumber},
              $form->{cusordnumber}, conv_date($form->{transdate}),
              conv_i($form->{vendor_id}), conv_i($form->{customer_id}),
-             $amount, $netamount, conv_date($reqdate),
+             $amount, $netamount, conv_date($reqdate), conv_date($form->{tax_point}),
              $form->{taxincluded} ? 't' : 'f', $form->{shippingpoint},
              $form->{shipvia}, $restricter->process($form->{notes}), $form->{intnotes},
              $form->{currency}, $form->{closed} ? 't' : 'f',
@@ -1017,7 +1017,7 @@ sub _retrieve {
            o.taxincluded, o.shippingpoint, o.shipvia, o.notes, o.intnotes,
            (SELECT cu.name FROM currencies cu WHERE cu.id=o.currency_id) AS currency, e.name AS employee, o.employee_id, o.salesman_id,
            o.${vc}_id, cv.name AS ${vc}, o.amount AS invtotal,
-           o.closed, o.reqdate, o.quonumber, o.department_id, o.cusordnumber,
+           o.closed, o.reqdate, o.tax_point, o.quonumber, o.department_id, o.cusordnumber,
            o.mtime, o.itime,
            d.description AS department, o.payment_id, o.language_id, o.taxzone_id,
            o.delivery_customer_id, o.delivery_vendor_id, o.proforma, o.shipto_id,
@@ -1096,7 +1096,7 @@ sub _retrieve {
       map { $form->{$_} =~ s/ +$//g } qw(printed emailed queued);
     }    # if !@ids
 
-    my $transdate = $form->{transdate} ? $dbh->quote($form->{transdate}) : "current_date";
+    my $transdate = $form->{tax_point} ? $dbh->quote($form->{tax_point}) : $form->{transdate} ? $dbh->quote($form->{transdate}) : "current_date";
 
     $form->{taxzone_id} = 0 unless ($form->{taxzone_id});
     unshift @values, ($form->{taxzone_id}) x 2;
index 2b45d97d13ae960df1c8fc975ca64f6fb3355f25..8929dc5e28f32b5bdf6479a083523662c01d3ed8 100644 (file)
@@ -370,10 +370,11 @@ sub date_tag {
   $::request->layout->add_javascripts('kivi.Validator.js');
   $::request->presenter->need_reinit_widgets($params{id});
 
+  $params{'data-validate'} = join(' ', "date", grep { $_ } (delete $params{'data-validate'}));
+
   input_tag(
     $name, blessed($value) ? $value->to_lxoffice : $value,
     size   => 11,
-    "data-validate" => "date",
     %params,
     %class, @onchange,
   );
diff --git a/SL/X.pm b/SL/X.pm
index 9343deab012d33a5b3cdbd94de1d4056697adedd..c3533e2efb7fe72ba24788517660e701175e402c 100644 (file)
--- a/SL/X.pm
+++ b/SL/X.pm
@@ -30,6 +30,16 @@ use Exception::Class (
   'SL::X::ZUGFeRDValidation' => {
     isa                 => 'SL::X::Base',
   },
+  'SL::X::Inventory' => {
+    isa                 => 'SL::X::Base',
+    fields              => [ qw(msg error) ],
+    defaults            => { error_template => [ '%s: %s', qw(msg) ] },
+  },
+  'SL::X::Inventory::Allocation' => {
+    isa                 => 'SL::X::Base',
+    fields              => [ qw(msg error) ],
+    defaults            => { error_template => [ '%s: %s', qw(msg) ] },
+  },
 );
 
 1;
index 2fe8041c9f5b3fedfa8dc00469878f48b6948e9e..afc2ab55855f142c33b06c7b5bdf6987f4d74f9b 100644 (file)
@@ -48,6 +48,7 @@ use SL::DB::Chart;
 use SL::DB::Currency;
 use SL::DB::Default;
 use SL::DB::Order;
+use SL::DB::PaymentTerm;
 use SL::DB::PurchaseInvoice;
 use SL::DB::RecordTemplate;
 use SL::DB::Tax;
@@ -129,6 +130,7 @@ sub load_record_template {
   $::form->{currency}         = $template->currency->name;
   $::form->{direct_debit}     = $template->direct_debit;
   $::form->{globalproject_id} = $template->project_id;
+  $::form->{payment_id}       = $template->payment_id;
   $::form->{AP_chart_id}      = $template->ar_ap_chart_id;
   $::form->{transdate}        = $today->to_kivitendo;
   $::form->{duedate}          = $today->to_kivitendo;
@@ -212,6 +214,7 @@ sub save_record_template {
     vendor_id      => $::form->{vendor_id}        || undef,
     department_id  => $::form->{department_id}    || undef,
     project_id     => $::form->{globalproject_id} || undef,
+    payment_id     => $::form->{payment_id}       || undef,
     taxincluded    => $::form->{taxincluded}  ? 1 : 0,
     direct_debit   => $::form->{direct_debit} ? 1 : 0,
     ordnumber      => $::form->{ordnumber},
@@ -252,7 +255,12 @@ sub add {
   $form->{transdate} = $form->{initial_transdate};
 
   if ($form->{vendor_id}) {
-    my $last_used_ap_chart = SL::DB::Vendor->load_cached($form->{vendor_id})->last_used_ap_chart;
+    my $vendor = SL::DB::Vendor->load_cached($form->{vendor_id});
+
+    # set initial payment terms
+    $form->{payment_id} = $vendor->payment_id;
+
+    my $last_used_ap_chart = $vendor->last_used_ap_chart;
     $form->{"AP_amount_chart_id_1"} = $last_used_ap_chart->id if $last_used_ap_chart;
   }
 
@@ -443,7 +451,7 @@ sub form_header {
   my $follow_up_vc         = $form->{vendor_id} ? SL::DB::Vendor->load_cached($form->{vendor_id})->name : '';
   my $follow_up_trans_info =  "$form->{invnumber} ($follow_up_vc)";
 
-  $::request->layout->add_javascripts("autocomplete_chart.js", "show_vc_details.js", "show_history.js", "follow_up.js", "kivi.Draft.js", "kivi.GL.js", "kivi.RecordTemplate.js", "kivi.File.js", "kivi.AP.js", "kivi.CustomerVendor.js", "kivi.Validator.js", "autocomplete_project.js");
+  $::request->layout->add_javascripts("autocomplete_chart.js", "show_vc_details.js", "show_history.js", "follow_up.js", "kivi.Draft.js", "kivi.SalesPurchase.js", "kivi.GL.js", "kivi.RecordTemplate.js", "kivi.File.js", "kivi.AP.js", "kivi.CustomerVendor.js", "kivi.Validator.js", "autocomplete_project.js");
   # $form->{totalpaid} is used by the action bar setup to determine
   # whether or not canceling is allowed. Therefore it must be
   # calculated prior to the action bar setup.
@@ -556,6 +564,7 @@ sub form_header {
   print $form->parse_html_template('ap/form_header', {
     today => DateTime->today,
     currencies => SL::DB::Manager::Currency->get_all_sorted,
+    payment_terms => SL::DB::Manager::PaymentTerm->get_all_sorted(query => [ or => [ obsolete => 0, id => $::form->{payment_id}*1 ]]),
   });
 
   $main::lxdebug->leave_sub();
@@ -656,8 +665,14 @@ sub update {
 
   if (($form->{previous_vendor_id} || $form->{vendor_id}) != $form->{vendor_id}) {
     IR->get_vendor(\%::myconfig, $form);
+
+    my $vendor = SL::DB::Vendor->load_cached($form->{vendor_id});
+
+    # reset payment to new vendor
+    $form->{payment_id} = $vendor->payment_id;
+
     if (($form->{rowcount} == 1) && ($form->{amount_1} == 0)) {
-      my $last_used_ap_chart = SL::DB::Vendor->load_cached($form->{vendor_id})->last_used_ap_chart;
+      my $last_used_ap_chart = $vendor->last_used_ap_chart;
       $form->{"AP_amount_chart_id_1"} = $last_used_ap_chart->id if $last_used_ap_chart;
     }
   }
index 256fe11627c665de02ac7d8fce552578e1d5796d..6f87019c9c37978a6a390af096e12ac557dd6f0b 100644 (file)
@@ -792,13 +792,6 @@ sub display_rows {
   $form->{totaldebit}  = 0;
   $form->{totalcredit} = 0;
 
-  my %project_labels = ();
-  my @project_values = ("");
-  foreach my $item (@{ $form->{"ALL_PROJECTS"} }) {
-    push(@project_values, $item->{"id"});
-    $project_labels{$item->{"id"}} = $item->{"projectnumber"};
-  }
-
   my %charts_by_id  = map { ($_->{id} => $_) } @{ $::form->{ALL_CHARTS} };
   my $default_chart = $::form->{ALL_CHARTS}[0];
   my $transdate     = $::form->{transdate} ? DateTime->from_kivitendo($::form->{transdate}) : DateTime->today_local;
@@ -914,13 +907,8 @@ sub display_rows {
       }
     }
 
-    my $projectnumber =
-      NTI($cgi->popup_menu('-name' => "project_id_$i",
-                           '-values' => \@project_values,
-                           '-labels' => \%project_labels,
-                           '-default' => $form->{"project_id_$i"} ));
-    my $projectnumber_hidden = qq|
-    <input type="hidden" name="project_id_$i" value="$form->{"project_id_$i"}">|;
+    my $projectnumber = SL::Presenter::Project::picker("project_id_$i", $form->{"project_id_$i"});
+    my $projectnumber_hidden = SL::Presenter::Tag::hidden_tag("project_id_$i", $form->{"project_id_$i"});
 
     my $copy2credit = $i == 1 ? 'onkeyup="copy_debit_to_credit()"' : '';
     my $balance     = $form->format_amount(\%::myconfig, $balances{$accno_id} // 0, 2, 'DRCR');
@@ -1099,16 +1087,15 @@ sub form_header {
 
   my ($init) = @_;
 
-  $::request->layout->add_javascripts("autocomplete_chart.js", "kivi.File.js", "kivi.GL.js", "kivi.RecordTemplate.js");
-
-  my @old_project_ids = grep { $_ } map{ $::form->{"project_id_$_"} } 1..$::form->{rowcount};
+  $::request->layout->add_javascripts("autocomplete_chart.js", "autocomplete_project.js", "kivi.File.js", "kivi.GL.js", "kivi.RecordTemplate.js", "kivi.Validator.js");
 
-  $::form->get_lists("projects"  => { "key"       => "ALL_PROJECTS",
-                                    "all"       => 0,
-                                    "old_id"    => \@old_project_ids },
+  my @old_project_ids     = grep { $_ } map{ $::form->{"project_id_$_"} } 1..$::form->{rowcount};
+  my @conditions          = @old_project_ids ? (id => \@old_project_ids) : ();
+  $::form->{ALL_PROJECTS} = SL::DB::Manager::Project->get_all_sorted(query => [ or => [ active => 1, @conditions ]]);
 
-                   "charts"    => { "key"       => "ALL_CHARTS",
-                                    "transdate" => $::form->{transdate} });
+  $::form->get_lists(
+    "charts"    => { "key" => "ALL_CHARTS", "transdate" => $::form->{transdate} },
+  );
 
   # we cannot book on charttype header
   @{ $::form->{ALL_CHARTS} } = grep { $_->{charttype} ne 'H' }  @{ $::form->{ALL_CHARTS} };
index 3f43c15c845bb959fb42e4552015e2e3fe332e95..0a6b861d0d6b90aaa88269fdc14b8a9dd85ad1e8 100644 (file)
@@ -328,7 +328,7 @@ sub display_row {
       $ship_qty          /= ( $all_units->{$form->{"unit_$i"}}->{factor} || 1 );
 
       $column_data{ship}  = $form->format_amount(\%myconfig, $form->round_amount($ship_qty, 2) * 1) . ' ' . $form->{"unit_$i"}
-      . $cgi->hidden(-name => "ship_$i", -value => $form->format_amount(\%myconfig, $form->{"ship_$i"}, $qty_dec));
+      . $cgi->hidden(-name => "ship_$i", -value => $form->{"ship_$i"}, $qty_dec);
 
       my $ship_missing_qty    = $form->{"qty_$i"} - $ship_qty;
       my $ship_missing_amount = $form->round_amount($ship_missing_qty * $form->{"sellprice_$i"} * (100 - $form->{"discount_$i"}) / 100 / $price_factor, 2);
@@ -1409,7 +1409,7 @@ sub print_form {
 
   # Format dates.
   format_dates($output_dateformat, $output_longdates,
-               qw(invdate orddate quodate pldate duedate reqdate transdate
+               qw(invdate orddate quodate pldate duedate reqdate transdate tax_point
                   shippingdate deliverydate validitydate paymentdate
                   datepaid transdate_oe transdate_do transdate_quo deliverydate_oe dodate
                   employee_startdate employee_enddate
index 82f2906a680125ae803d656f68115b55c22d4848..d2cceeaf30b49481b6ec9e087fa6d43c1f7f50c9 100644 (file)
@@ -2050,8 +2050,7 @@ sub delivery_order {
   $main::lxdebug->leave_sub();
 }
 
-sub oe_delivery_order_from_order {
-
+sub oe_prepare_xyz_from_order {
   return if !$::form->{id};
 
   my $order = SL::DB::Order->new(id => $::form->{id})->load;
@@ -2068,27 +2067,15 @@ sub oe_delivery_order_from_order {
   $::form->{rowcount}++;
 
   _update_ship();
+}
+
+sub oe_delivery_order_from_order {
+  oe_prepare_xyz_from_order();
   delivery_order();
 }
 
 sub oe_invoice_from_order {
-
-  return if !$::form->{id};
-
-  my $order = SL::DB::Order->new(id => $::form->{id})->load;
-  $order->flatten_to_form($::form, format_amounts => 1);
-
-  # hack: add partsgroup for first row if it does not exists,
-  # because _remove_billed_or_delivered_rows and _remove_full_delivered_rows
-  # determine fields to handled by existing fields for the first row. If partsgroup
-  # is missing there, for deleted rows the partsgroup_field is not emptied and in
-  # update_delivery_order it will not considered an empty row ...
-  $::form->{partsgroup_1} = '' if !exists $::form->{partsgroup_1};
-
-  # fake last empty row
-  $::form->{rowcount}++;
-
-  _update_ship();
+  oe_prepare_xyz_from_order();
   invoice();
 }
 
@@ -2259,7 +2246,7 @@ sub _remove_full_delivered_rows {
     next unless $::form->{"id_$row"};
     my $base_factor = SL::DB::Manager::Unit->find_by(name => $::form->{"unit_$row"})->base_factor;
     my $base_qty = $::form->parse_amount(\%::myconfig, $::form->{"qty_$row"}) *  $base_factor;
-    my $ship_qty = $::form->parse_amount(\%::myconfig, $::form->{"ship_$row"}) *  $base_factor;
+    my $ship_qty = $::form->{"ship_$row"} *  $base_factor;
     #$main::lxdebug->message(LXDebug->DEBUG2(),"shipto=".$ship_qty." qty=".$base_qty);
 
     if (!$ship_qty || ($ship_qty < $base_qty)) {
index b78083e9980437159c203a761553b1fae1640657..ca4ad0aa1add784eb731e6e02d3c6e13971ab475 100755 (executable)
@@ -749,7 +749,7 @@ sub setup_sepa_edit_transfer_action_bar {
         accesskey => 'enter',
         tooltip   => t8('Post payments for selected invoices'),
         checks    => [ [ 'kivi.check_if_entries_selected', '[name="ids[]"]' ] ],
-        only_if   => $params{show_post_payments_button},
+        disabled  => $params{show_post_payments_button} ? undef : t8('All payments have already been posted.'),
       ],
       action => [
         t8('Payment list'),
@@ -757,7 +757,7 @@ sub setup_sepa_edit_transfer_action_bar {
         accesskey => 'enter',
         tooltip   => t8('Download list of payments as PDF'),
         checks    => [ [ 'kivi.check_if_entries_selected', '[name="ids[]"]' ] ],
-        not_if    => $params{show_post_payments_button},
+        disabled  => $params{show_post_payments_button} ? t8('All payments must be posted before the payment list can be downloaded.') : undef,
       ],
     );
   }
index 42bbf5dd7913b0968d933ee223db50bddf85a325..85127ab13f6e2d6242cd4f62739ec6eb17de860e 100644 (file)
@@ -12,6 +12,10 @@ Mittelgroße neue Features:
 
 Kleinere neue Features und Detailverbesserungen:
 
+ - Inventur-Makse: Part-Picker sucht auch nach Lieferanten-Artikelnummer
+ - Einkaufs-/Verkaufsbelege und Buchungsmasken: Neues Feld Leistungsdatum,
+   welches die Steuerberechnung beeinflusst. I.d.R. gilt für die Steuer:
+   Leistungsdatum. Wenn leer, dann Lieferdatum; wenn leer, dann Belegdatum.
  - Neuer Order-Controller: Unterstützung für Übersetzungen von
    Artikeln wurde implementiert.
  - Einkaufs-/Verkaufsbelege: die Belegsprache ist nun als Auswahl
index 19609099d42b232e0643a83b0a940a3a1f0c8ef9..e2850f593718db5fedec34d0a38b50af7c6ff837 100755 (executable)
@@ -263,6 +263,8 @@ $self->{texts} = {
   'All groups'                  => 'Alle Gruppen',
   'All modules'                 => 'Alle Module',
   'All partsgroups'             => 'Alle Warengruppen',
+  'All payments have already been posted.' => 'Es wurden bereits alle Zahlungen verbucht.',
+  'All payments must be posted before the payment list can be downloaded.' => 'Alle Zahlungen müssen verbucht werden, bevor die Zahlungsliste heruntergeladen werden kann.',
   'All price sources'           => 'Alle Preisquellen',
   'All reports'                 => 'Alle Berichte (Kontenübersicht, Summen- u. Saldenliste, Erfolgsrechnung, GuV, BWA, Bilanz, Projektbuchungen)',
   'All the other clients will start with an empty set of WebDAV folders.' => 'Alle anderen Mandanten werden mit einem leeren Satz von Dokumenten-Ordnern ausgestattet.',
@@ -270,6 +272,7 @@ $self->{texts} = {
   'All transactions'            => 'Alle Buchungen',
   'All units have either no or exactly one base unit of which they are multiples.' => 'Einheiten haben entweder keine oder genau eine Basiseinheit, von der sie ein Vielfaches sind.',
   'All users'                   => 'Alle BenutzerInnen',
+  'Allocations didn\'t pass constraints' => 'Keine Verfügbarkeit wegen Lagereinschränkung',
   'Allow access'                => 'Zugriff erlauben',
   'Allow conversion from sales orders to sales invoices' => 'Umwandlung von Verkaufsaufträgen in Verkaufsrechnungen zulassen',
   'Allow conversion from sales quotations to sales invoices' => 'Umwandlung von Verkaufsangeboten in Verkaufsrechnungen zulassen',
@@ -533,6 +536,7 @@ $self->{texts} = {
   'Cancel Accounts Payables Transaction' => 'Kreditorenbuchung stornieren',
   'Cancel Accounts Receivables Transaction' => 'Debitorenbuchung stornieren',
   'Cancelling is disallowed. Either undo or balance the current payments until the open amount matches the invoice amount' => 'Storno verboten, da Zahlungen zum Beleg vorhanden sind. Entweder die Zahlungen löschen oder mit umgekehrten Vorzeichen ausbuchen, sodass der offene Betrag dem Rechnungsbetrag entspricht.',
+  'Cannot allocate parts.'      => 'Es sind nicht genügend Artikel vorhanden',
   'Cannot change transaction in a closed period!' => 'In einem bereits abgeschlossenen Zeitraum kann keine Buchung verändert werden!',
   'Cannot check correct WebDAV folder' => 'Kann nicht den richtigen WebDAV Pfad überprüfen',
   'Cannot delete account!'      => 'Konto kann nicht gelöscht werden!',
@@ -598,6 +602,7 @@ $self->{texts} = {
   'Charge'                      => 'Berechnen',
   'Charge Number'               => 'Chargennummer',
   'Charge number'               => 'Chargennummer',
+  'Chargenumbers'               => 'Chargennummern',
   'Charset'                     => 'Zeichensatz',
   'Chart'                       => 'Buchungskonto',
   'Chart Type'                  => 'Kontentyp',
@@ -3184,6 +3189,7 @@ $self->{texts} = {
   'Tax deleted!'                => 'Steuer gelöscht!',
   'Tax number'                  => 'Steuernummer',
   'Tax paid'                    => 'Vorsteuer',
+  'Tax point'                   => 'Leistungsdatum',
   'Tax rate'                    => 'Steuersatz',
   'Tax saved!'                  => 'Steuer gespeichert!',
   'Tax zone'                    => 'Steuerzone',
@@ -4124,6 +4130,7 @@ $self->{texts} = {
   'brutto'                      => 'brutto',
   'building data'               => 'Verarbeite Daten',
   'building report'             => 'Erstelle Bericht',
+  'can not allocate #1 units of #2, missing #3 units' => 'Kann keine #1 Einheiten von #2 belegen, es fehlen #3 Einheiten',
   'can only parse a pdf file'   => 'Kann nur eine gültige PDF-Datei verwenden.',
   'cash'                        => 'Ist-Versteuerung',
   'chargenumber #1'             => 'Chargennummer #1',
@@ -4272,6 +4279,7 @@ $self->{texts} = {
   'our vendor number at customer' => 'Unsere Lieferanten-Nr. beim Kunden',
   'parsing csv'                 => 'Parse CSV Daten',
   'part'                        => 'Ware',
+  'part \'#\'1 in bin \'#2\' only with qty #3 (need additional #4) and chargenumber \'#5\'.' => 'Artikel \'#1\' im \'#2\' nur mit der Menge #3 (noch #4 benötig) und Chargennummer \'#5\'.',
   'part_list'                   => 'Warenliste',
   'percental'                   => 'prozentual',
   'periodic'                    => 'Aufwandsmethode',
diff --git a/sql/Pg-upgrade2/record_template_payment_id.sql b/sql/Pg-upgrade2/record_template_payment_id.sql
new file mode 100644 (file)
index 0000000..e251e3d
--- /dev/null
@@ -0,0 +1,5 @@
+-- @tag: record_template_payment_id
+-- @description: Zahlungsbedingungen in Vorlagen in der Finanzbuchhaltung
+-- @depends: release_3_5_6_1
+
+ALTER TABLE record_templates ADD COLUMN payment_id INTEGER REFERENCES payment_terms(id);
diff --git a/sql/Pg-upgrade2/tax_point.sql b/sql/Pg-upgrade2/tax_point.sql
new file mode 100644 (file)
index 0000000..7fcb65d
--- /dev/null
@@ -0,0 +1,7 @@
+-- @tag: tax_point
+-- @description: Feld Leistungsdatum in Einkaufs- & Verkaufsbelegen
+-- @depends: release_3_5_6_1
+ALTER TABLE ap ADD COLUMN tax_point DATE;
+ALTER TABLE ar ADD COLUMN tax_point DATE;
+ALTER TABLE gl ADD COLUMN tax_point DATE;
+ALTER TABLE oe ADD COLUMN tax_point DATE;
diff --git a/sql/Pg-upgrade2/tax_point2.sql b/sql/Pg-upgrade2/tax_point2.sql
new file mode 100644 (file)
index 0000000..6caf13d
--- /dev/null
@@ -0,0 +1,4 @@
+-- @tag: tax_point2
+-- @description: Feld Leistungsdatum in Lieferscheinen
+-- @depends: tax_point
+ALTER TABLE delivery_orders ADD COLUMN tax_point DATE;
index e2bb552075b33f2beb2852644b6d9e13f620ee53..6ea6b687cf4bb5eacd7decace987d04c392bd61a 100644 (file)
@@ -711,8 +711,8 @@ sub test_credit_note {
                                                                 transdate     => $dt_10,
                                                                );
   my ($agreement, $rule_matches) = $bt->get_agreement_with_invoice($credit_note);
-  is($agreement, 13, "points for credit note ok");
-  is($rule_matches, 'remote_account_number(3) exact_amount(4) wrong_sign(-1) depositor_matches(2) remote_name(2) payment_within_30_days(1) datebonus14(2) ', "rules_matches for credit note ok");
+  is($agreement, 14, "points for credit note ok");
+  is($rule_matches, 'remote_account_number(3) exact_amount(4) depositor_matches(2) remote_name(2) payment_within_30_days(1) datebonus14(2) ', "rules_matches for credit note ok");
 
   $::form->{invoice_ids} = {
     $bt->id => [ $credit_note->id ]
@@ -764,7 +764,7 @@ sub test_neg_ap_transaction {
                                                                );
 
   my ($agreement, $rule_matches) = $bt->get_agreement_with_invoice($invoice);
-  is($agreement, 15, "points for negative ap transaction ok");
+  is($agreement, 16, "points for negative ap transaction ok");
 
   $::form->{invoice_ids} = {
     $bt->id => [ $invoice->id ]
@@ -1227,4 +1227,36 @@ sub test_closedto {
 
 }
 
+sub test_skonto_exact_ap_transaction {
+
+  my $testname = 'test_skonto_exact_ap_transaction';
+
+  $ap_transaction = test_ap_transaction(invnumber => 'ap transaction skonto',
+                                        payment_id => $payment_terms->id,
+                                       );
+
+  my $bt = create_bank_transaction(record        => $ap_transaction,
+                                   bank_chart_id => $bank->id,
+                                   transdate     => $dt,
+                                   valutadate    => $dt,
+                                   amount        => $ap_transaction->amount_less_skonto
+                                  ) or die "Couldn't create bank_transaction";
+
+  $::form->{invoice_ids} = {
+    $bt->id => [ $ap_transaction->id ]
+  };
+  $::form->{invoice_skontos} = {
+    $bt->id => [ 'with_skonto_pt' ]
+  };
+
+  save_btcontroller_to_string();
+
+  $ap_transaction->load;
+  $bt->load;
+  is($ap_transaction->paid   , '119.00000' , "$testname: ap transaction skonto was paid");
+  is($ap_transaction->closed , 1           , "$testname: ap transaction skonto is closed");
+  is($bt->invoice_amount     , '113.05000' , "$testname: bt invoice amount was assigned");
+
+};
+
 1;
diff --git a/t/wh/inventory.t b/t/wh/inventory.t
new file mode 100644 (file)
index 0000000..a10b9cf
--- /dev/null
@@ -0,0 +1,285 @@
+use strict;
+use Test::More;
+use Test::Exception;
+
+use lib 't';
+
+use SL::Dev::Part qw(new_part new_assembly);
+use SL::Dev::Inventory qw(create_warehouse_and_bins set_stock);
+use SL::Dev::Record qw(create_sales_order);
+
+use_ok 'Support::TestSetup';
+use_ok 'SL::DB::Bin';
+use_ok 'SL::DB::Part';
+use_ok 'SL::DB::Warehouse';
+use_ok 'SL::DB::Inventory';
+use_ok 'SL::WH';
+use_ok 'SL::Helper::Inventory';
+
+Support::TestSetup::login();
+
+my ($wh, $bin1, $bin2, $assembly1, $part1, $part2);
+
+reset_db();
+create_standard_stock();
+
+
+# simple stock in, get_stock, get_onhand
+set_stock(
+  part => $part1,
+  qty => 25,
+  bin => $bin1,
+);
+
+is(SL::Helper::Inventory::get_stock(part => $part1), "25.00000", 'simple get_stock works');
+is(SL::Helper::Inventory::get_onhand(part => $part1), "25.00000", 'simple get_onhand works');
+
+# stock on some more, get_stock, get_onhand
+
+WH->transfer({
+  parts_id          => $part1->id,
+  qty               => 15,
+  transfer_type     => 'stock',
+  dst_warehouse_id  => $bin1->warehouse_id,
+  dst_bin_id        => $bin1->id,
+  comment           => 'more',
+});
+
+WH->transfer({
+  parts_id          => $part1->id,
+  qty               => 20,
+  transfer_type     => 'stock',
+  chargenumber      => '298345',
+  dst_warehouse_id  => $bin1->warehouse_id,
+  dst_bin_id        => $bin1->id,
+  comment           => 'more',
+});
+
+is(SL::Helper::Inventory::get_stock(part => $part1), "60.00000", 'normal get_stock works');
+is(SL::Helper::Inventory::get_onhand(part => $part1), "60.00000", 'normal get_onhand works');
+
+# allocate some stuff
+
+my @allocations = SL::Helper::Inventory::allocate(
+  part => $part1,
+  qty  => 12,
+);
+
+is_deeply(\%{ $allocations[0] }, {
+   bestbefore        => undef,
+   bin_id            => $bin1->id,
+   chargenumber      => '',
+   parts_id          => $part1->id,
+   qty               => 12,
+   warehouse_id      => $wh->id,
+   comment           => undef, # comment is not a partition so is not set by allocate
+   for_object_id     => undef,
+ }, 'allocation works');
+
+# allocate something where more than one result will match
+
+@allocations = SL::Helper::Inventory::allocate(
+  part => $part1,
+  qty  => 55,
+);
+
+is_deeply(\@allocations, [
+  {
+    bestbefore        => undef,
+    bin_id            => $bin1->id,
+    chargenumber      => '',
+    parts_id          => $part1->id,
+    qty               => '40.00000',
+    warehouse_id      => $wh->id,
+    comment           => undef,
+    for_object_id     => undef,
+  },
+  {
+    bestbefore        => undef,
+    bin_id            => $bin1->id,
+    chargenumber      => '298345',
+    parts_id          => $part1->id,
+    qty               => '15',
+    warehouse_id      => $wh->id,
+    comment           => undef,
+    for_object_id     => undef,
+  }
+], 'complex allocation works');
+
+# try to allocate too much
+
+dies_ok(sub {
+  SL::Helper::Inventory::allocate(part => $part1, qty => 100)
+},
+"allocate too much dies");
+
+# produce something
+
+reset_db();
+create_standard_stock();
+
+set_stock(
+  part => $part1,
+  qty => 5,
+  bin => $bin1,
+);
+set_stock(
+  part => $part2,
+  qty => 10,
+  bin => $bin1,
+);
+
+
+my @alloc1 = SL::Helper::Inventory::allocate(part => $part1, qty => 3);
+my @alloc2 = SL::Helper::Inventory::allocate(part => $part2, qty => 3);
+
+SL::Helper::Inventory::produce_assembly(
+  part          => $assembly1,
+  qty           => 3,
+  allocations => [ @alloc1, @alloc2 ],
+
+  # where to put it
+  bin          => $bin1,
+  chargenumber => "537",
+);
+
+is(SL::Helper::Inventory::get_stock(part => $assembly1), "3.00000", 'produce works');
+is(SL::Helper::Inventory::get_stock(part => $part1), "2.00000", 'and consumes...');
+is(SL::Helper::Inventory::get_stock(part => $part2), "7.00000", '..the materials');
+
+# produce the same using auto_allocation
+
+reset_db();
+create_standard_stock();
+
+set_stock(
+  part => $part1,
+  qty => 5,
+  bin => $bin1,
+);
+set_stock(
+  part => $part2,
+  qty => 10,
+  bin => $bin1,
+);
+
+SL::Helper::Inventory::produce_assembly(
+  part          => $assembly1,
+  qty           => 3,
+  auto_allocate => 1,
+
+  # where to put it
+  bin          => $bin1,
+  chargenumber => "537",
+);
+
+is(SL::Helper::Inventory::get_stock(part => $assembly1), "3.00000", 'produce with auto allocation works');
+is(SL::Helper::Inventory::get_stock(part => $part1), "2.00000", 'and consumes...');
+is(SL::Helper::Inventory::get_stock(part => $part2), "7.00000", '..the materials');
+
+# try to produce without allocations dies
+
+dies_ok(sub {
+SL::Helper::Inventory::produce_assembly(
+  part          => $assembly1,
+  qty           => 3,
+
+  # where to put it
+  bin          => $bin1,
+  chargenumber => "537",
+);
+}, "producing without allocations dies");
+
+# try to produce with insufficient allocations dies
+
+@alloc1 = SL::Helper::Inventory::allocate(part => $part1, qty => 1);
+@alloc2 = SL::Helper::Inventory::allocate(part => $part2, qty => 1);
+
+dies_ok(sub {
+SL::Helper::Inventory::produce_assembly(
+  part          => $assembly1,
+  qty           => 3,
+  allocations => [ @alloc1, @alloc2 ],
+
+  # where to put it
+  bin          => $bin1,
+  chargenumber => "537",
+);
+}, "producing with insufficient allocations dies");
+
+
+
+# bestbefore tests
+
+reset_db();
+create_standard_stock();
+
+set_stock(
+  part => $part1,
+  qty => 5,
+  bin => $bin1,
+);
+set_stock(
+  part => $part2,
+  qty => 10,
+  bin => $bin1,
+);
+
+
+
+SL::Helper::Inventory::produce_assembly(
+  part          => $assembly1,
+  qty           => 3,
+  auto_allocate => 1,
+
+  bin               => $bin1,
+  chargenumber      => "537",
+  bestbefore        => DateTime->today->clone->add(days => -14), # expired 2 weeks ago
+  shippingdate      => DateTime->today->clone->add(days => 1),
+);
+
+is(SL::Helper::Inventory::get_stock(part => $assembly1), "3.00000", 'produce with bestbefore works');
+is(SL::Helper::Inventory::get_onhand(part => $assembly1), "3.00000", 'produce with bestbefore works');
+is(SL::Helper::Inventory::get_stock(
+  part       => $assembly1,
+  bestbefore => DateTime->today,
+), undef, 'get_stock with bestbefore date skips expired');
+{
+  local $::instance_conf->data->{show_bestbefore} = 1;
+  is(SL::Helper::Inventory::get_onhand(
+    part       => $assembly1,
+  ), undef, 'get_onhand with bestbefore skips expired as of today');
+}
+
+{
+  local $::instance_conf->data->{show_bestbefore} = 0;
+  is(SL::Helper::Inventory::get_onhand(
+    part       => $assembly1,
+  ), "3.00000", 'get_onhand without bestbefore finds all');
+}
+
+
+sub reset_db {
+  SL::DB::Manager::Order->delete_all(all => 1);
+  SL::DB::Manager::Inventory->delete_all(all => 1);
+  SL::DB::Manager::Assembly->delete_all(all => 1);
+  SL::DB::Manager::Part->delete_all(all => 1);
+  SL::DB::Manager::Bin->delete_all(all => 1);
+  SL::DB::Manager::Warehouse->delete_all(all => 1);
+}
+
+sub create_standard_stock {
+  ($wh, $bin1) = create_warehouse_and_bins();
+  $bin2 = SL::DB::Bin->new(description => "Bin 2", warehouse => $wh)->save;
+  $wh->load;
+
+  $assembly1  =  new_assembly(number_of_parts => 2)->save;
+  ($part1, $part2) = map { $_->part } $assembly1->assemblies;
+}
+
+
+reset_db();
+
+done_testing();
+
+1;
index abbc1a101898e11f62d5ce7ba81b5207a134f2bd..90ccaff513416da07cb54da11d9532c44dbf6a85 100644 (file)
@@ -162,7 +162,7 @@ contents={\usebox\shippingAddressBox}
 
 \prg_new_conditional:Nnn \kivi_if_Price_col:n {T} {
        \prop_get:cnN {l_kivi_col_#1_prop} {colspec} \l_tmpa_tl
-       \tl_if_eq:NnTF \l_tmpa_tl {Price}
+       \exp_args:NV \tl_if_eq:nnTF \l_tmpa_tl {Price}
                {\prg_return_true:}
                {\prg_return_false:}
 }
@@ -191,7 +191,7 @@ contents={\usebox\shippingAddressBox}
                                                \dim_use:c {l_kivi_tab_##1_dim}+2\g_kivi_tabcolsep_dim
                                        }
                                }
-                               \tl_gput_right:Nn \g_kivi_Pricing_colspec_tl {>{\raggedleft\arraybackslash}p{\dim_use:c {l_kivi_tab_##1_dim}}}
+                               \tl_gput_right:Nn \g_kivi_Pricing_colspec_tl {K{\dim_use:c {l_kivi_tab_##1_dim}}}
                                \kivi_if_Price_col:nT {##1} {\tl_gput_right:Nn \g_kivi_Pricing_colspec_tl {<{\__kivi_tab_column_currency:}}}
                        }
                }
@@ -200,7 +200,8 @@ contents={\usebox\shippingAddressBox}
        \tl_gput_right:Nn \g_kivi_Pricing_colspec_tl {@{}}
 }
 
-\newcolumntype{P}[1]{>{\raggedleft\arraybackslash}p{#1}<{\__kivi_tab_column_currency:}}
+\newcolumntype{K}[1]{>{\raggedleft\arraybackslash}p{#1}}
+\newcolumntype{P}[1]{K{#1}<{\__kivi_tab_column_currency:}}
 
 \RequirePackage{tcolorbox}
 \tcbuselibrary{breakable, skins}
index 6f73fd9d5e1eb352c3ed52505444bc99cb49e601..2f22a96f2c0334c0c4361ac46ab12ab25300e6db 100644 (file)
                 <th align="right" nowrap>[% 'Due Date' | $T8 %]</th>
                 <td>[% L.date_tag('duedate', duedate) %]</td>
               </tr>
+              <tr>
+                <th align="right" nowrap>[% LxERP.t8('Tax point') %]</th>
+                <td>[% L.date_tag('tax_point', tax_point, id='tax_point') %]</td>
+              </tr>
               <tr>
                 <th align=right nowrap>[% 'Delivery Date' | $T8 %]</th>
                 <td>[% L.date_tag('deliverydate', deliverydate) %]</td>
     </tr>
     <tr>
       <td>
-        <table width="100%">
-        <tr>
-          <th align="left" width="1%">[% 'Notes' | $T8 %]</th>
-          <td align="left">
-            <textarea name="notes" rows="[% textarea_rows %]" cols="50" wrap="soft" [% readonly %]>[% notes | html %]</textarea>
-          </td>
-
-          <th align="left" width=1%>[% 'Notes for vendor' | $T8 %]</th>
-          <td align="left">
-            <textarea name="intnotes" rows="[% textarea_rows %]" cols="50" wrap="soft" readonly>[% intnotes | html %]</textarea>
-          </td>
-        </tr>
-      </table>
+        <table>
+          <tr>
+           <th align="left">[% 'Notes' | $T8 %]</th>
+           <th align="left">[% 'Internal Notes' | $T8 %]</th>
+           <th align="left">[% 'Payment Terms' | $T8 %]</th>
+          </tr>
+          <tr valign="top">
+           <td>
+            [% L.textarea_tag("notes", notes, wrap="soft", rows=textarea_rows, cols=50, readonly=readonly) %]
+           </td>
+           <td>
+            [% L.textarea_tag("intnotes", intnotes, wrap="soft", rows=textarea_rows, cols=50, readonly=readonly) %]
+           </td>
+           <td>
+             [% L.select_tag('payment_id', payment_terms, default=payment_id, title_key='description', with_empty=1, style="width: 250px", onchange="kivi.SalesPurchase.set_duedate_on_reference_date_change('invdate')") %]
+           </td>
+          <tr>
+        </table>
     </td>
   </tr>
   <tr>
index c1908a41887bb68e6bc8faa325a43be99e62d790..755d564c0dce134c738192f48b2fb2e816c618b2 100644 (file)
                 <th align=right nowrap>[% 'Due Date' | $T8 %]</th>
                 <td>[% L.date_tag('duedate', duedate) %]</td>
               </tr>
+              <tr>
+                <th align="right" nowrap>[% LxERP.t8('Tax point') %]</th>
+                <td>[% L.date_tag('tax_point', tax_point, id='tax_point') %]</td>
+              </tr>
               <tr>
                 <th align=right nowrap>[% 'Delivery Date' | $T8 %]</th>
                 <td>[% L.date_tag('deliverydate', deliverydate) %]</td>
index 1cac15be3fa6ac193caf53157403c8da84601766..9ff8e98a153575707cdf5749fd3aa239aef605c5 100644 (file)
   <input type="hidden" name="type" id="type" value="[% HTML.escape(type) %]">
   <input type="hidden" name="vc" id="vc" value="[% HTML.escape(vc) %]">
   <input type="hidden" name="lastmtime" id="lastmtime" value="[% HTML.escape(lastmtime) %]">
+  <input type="hidden" name="tax_point" id="tax_point" value="[% HTML.escape(tax_point) %]">
 
   <p>
    <table width="100%">
index b021909bdb8f3d1416f27755ab58b8232b578ef6..ba9036c8df916cf61a823456243d3b75557145c5 100644 (file)
         <tr>
           <th [%- departments_style -%]align="right">[% 'Department' | $T8 %]</th>
           <td [%- departments_style -%]>[% L.select_tag('department_id', ALL_DEPARTMENTS, default = department_id, title_key = 'description', with_empty = 1) %]</td>
-          <th align=right>[% 'Delivery Date' | $T8 %]</th>
-          <td>[% L.date_tag('deliverydate', deliverydate) %]</td>
+          <th align=right>[% 'Tax point' | $T8 %]</th>
+          <td>[% L.date_tag('tax_point', tax_point) %]</td>
         </tr>
         <tr>
           <th align="right">[% 'Description' | $T8 %]</th>
           <td>[% L.areainput_tag('description', description, cols=50, readonly=readonly) %]</td>
-          <th align="right">[% 'MwSt. inkl.' | $T8 %]</th>
-          <td>[% L.checkbox_tag('taxincluded', checked=taxincluded) %]</td>
+          <th align=right>[% 'Delivery Date' | $T8 %]</th>
+          <td>[% L.date_tag('deliverydate', deliverydate) %]</td>
         </tr>
-[%- IF id %]
         <tr>
-          <th align="right">[% 'Mitarbeiter' | $T8 %]</th>
-          <td>[% L.input_tag('employee', employee, size=20, readonly=readonly) %]</td>
+          <th align="right">[%- IF id %][% 'Mitarbeiter' | $T8 %][% END %]</th>
+          <td>[%- IF id %][% L.input_tag('employee', employee, size=20, readonly=readonly) %][% END %]</td>
+          <th align="right">[% 'MwSt. inkl.' | $T8 %]</th>
+          <td>[% L.checkbox_tag('taxincluded', checked=taxincluded) %]</td>
         </tr>
-[%- END %]
 
       <tr>
        <td colspan=4>
index 0f85da304fd2c328b635d40f68d8f4e20426f647..ffe3600b484dabf2799960081748b653745134d1 100644 (file)
@@ -12,7 +12,7 @@
 
  <p>
   <label for="part_id">[% "Article" | $T8 %]</label>
-  [% P.part.picker("part_id", "") %]
+  [% P.part.picker("part_id", "", with_makemodel=1) %]
  </p>
 
  <p>
index 1c652d8930ae4a60390860249ca0a5f3a510579e..fac160a03813f1a123ec4c62df29df081af3fd56 100644 (file)
            <span id="duedate_fixed"[% IF !payment_terms_obj.auto_calculation %] style="display:none"[% END %]>[% HTML.escape(duedate) %]</span>
           </td>
         </tr>
+        <tr>
+          <th align="right" nowrap>[% LxERP.t8('Tax point') %]</th>
+          <td nowrap>[% L.date_tag('tax_point', tax_point, id='tax_point') %]</td>
+        </tr>
         <tr>
           <th align="right">[% 'Delivery Date' | $T8 %]</th>
           <td>[% L.date_tag('deliverydate', deliverydate) %]</td>
index e30974023df853bee70d5497bdfbb4a44e86efeb..c23c162294ac58f131e64b062faf937d1e2c02fb 100644 (file)
            <span id="duedate_fixed"[% IF !payment_terms_obj.auto_calculation %] style="display:none"[% END %]>[% HTML.escape(duedate) %]</span>
           </td>
         </tr>
+[%- END %]
+        <tr>
+          <th align="right" nowrap>[% LxERP.t8('Tax point') %]</th>
+          <td nowrap>[% L.date_tag('tax_point', tax_point, id='tax_point') %]</td>
+        </tr>
+[%- IF !is_type_credit_note %]
         <tr>
-        <th align="right" nowrap>[% 'Delivery Order Number' | $T8 %]</th>
+          <th align="right" nowrap>[% 'Delivery Order Number' | $T8 %]</th>
           <td colspan="3"><input size='11' name="donumber" id="donumber" value="[% HTML.escape(donumber) %]"></td>
         </tr>
 [%- END %]
index 556987417838b88972f40a2fa2f04e036214c449..2390718f30087043722885f44f7a759e1191d6d3 100644 (file)
                       [% L.date_tag('transdate', transdate, id='transdate') %]
                     </td>
                   </tr>
+                  <tr>
+                    <th align="right" nowrap>[% LxERP.t8('Tax point') %]</th>
+                    <td nowrap>[% L.date_tag('tax_point', tax_point, id='tax_point') %]</td>
+                  </tr>
                   <tr>
                     <th align="right" nowrap>
                      [%- IF is_sales_quo %]
index d7b84ff88e78c08fe18bdafee831504d109798b8..05a663c1c0b7b6158c1ceb56314c8396024959df 100644 (file)
             <td>[% L.date_tag('order.transdate_as_date', SELF.order.transdate_as_date) %]</td>
           </tr>
 
+          <tr>
+            <th width="70%" align="right" nowrap>[% 'Tax point' | $T8 %]</th>
+            <td>[% L.date_tag('order.tax_point_as_date', SELF.order.tax_point_as_date, class="recalc") %]</td>
+          </tr>
+
           [%- IF (SELF.type == "sales_order" || SELF.type == "purchase_order") -%]
-            [%- SET reqdate_txt = 'Reqdate' -%]
+            [%- SET reqdate_txt = 'Reqdate'; SET reqdate_class = 'recalc' -%]
           [%- ELSIF SELF.type == "sales_quotation" -%]
-            [%- SET reqdate_txt = 'Valid until' -%]
+            [%- SET reqdate_txt = 'Valid until'; SET reqdate_class = '' -%]
           [%- ELSE -%]
-            [%- SET reqdate_txt = 'Required by' -%]
+            [%- SET reqdate_txt = 'Required by'; SET reqdate_class = 'recalc' -%]
           [%- END -%]
           <tr>
             <th width="70%" align="right" nowrap>[% reqdate_txt | $T8 %]</th>
-            <td>[% L.date_tag('order.reqdate_as_date', SELF.order.reqdate_as_date) %]</td>
+            <td>[% L.date_tag('order.reqdate_as_date', SELF.order.reqdate_as_date, class=reqdate_class) %]</td>
           </tr>
 
           [%- IF SELF.type == "sales_quotation" -%]
           </tr>
           <tr>
             <th width="70%" align="right" nowrap>[% 'Expected billing date' | $T8 %]</th>
-            <td>[%- L.date_tag('order.expected_billing_date_as_date', SELF.order.expected_billing_date_sa_date) %]</td>
+            <td>[%- L.date_tag('order.expected_billing_date_as_date', SELF.order.expected_billing_date_as_date) %]</td>
           </tr>
           [%- END %]