+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>
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>
$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},
$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},
$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,
# 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;
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
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
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
: '';
}
+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__
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,
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,
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,
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,
);
}
#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'} . ') ';
}
_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' ) {
$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 }) {
$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;
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';
$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,
$self->update_attributes(paid => $self->amount);
}
+sub effective_tax_point {
+ my ($self) = @_;
+
+ return $self->tax_point || $self->deliverydate || $self->transdate;
+}
+
1;
__END__
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' },
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' },
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' },
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' },
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' },
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 },
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' },
}
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 {
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$}),
# 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)) {
$self->update_attributes(paid => $self->amount);
}
+sub effective_tax_point {
+ my ($self) = @_;
+
+ return $self->tax_point || $self->deliverydate || $self->transdate;
+}
+
1;
$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 = ?,
@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}),
# 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},
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);
}
}
$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,
# 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})));
$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);
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,
--- /dev/null
+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
--- /dev/null
+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
# 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 "") {
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);
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 = ?),
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"},
# 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,
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);
$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} }) {
$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 = ?,
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"},
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,
$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";
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;
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;
$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 = ?,
@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',
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,
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;
$::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,
);
'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;
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;
$::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;
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},
$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;
}
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.
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();
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;
}
}
$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;
}
}
- 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');
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} };
$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);
# 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
$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;
$::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();
}
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)) {
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'),
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,
],
);
}
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
'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.',
'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',
'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!',
'Charge' => 'Berechnen',
'Charge Number' => 'Chargennummer',
'Charge number' => 'Chargennummer',
+ 'Chargenumbers' => 'Chargennummern',
'Charset' => 'Zeichensatz',
'Chart' => 'Buchungskonto',
'Chart Type' => 'Kontentyp',
'Tax deleted!' => 'Steuer gelöscht!',
'Tax number' => 'Steuernummer',
'Tax paid' => 'Vorsteuer',
+ 'Tax point' => 'Leistungsdatum',
'Tax rate' => 'Steuersatz',
'Tax saved!' => 'Steuer gespeichert!',
'Tax zone' => 'Steuerzone',
'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',
'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',
--- /dev/null
+-- @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);
--- /dev/null
+-- @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;
--- /dev/null
+-- @tag: tax_point2
+-- @description: Feld Leistungsdatum in Lieferscheinen
+-- @depends: tax_point
+ALTER TABLE delivery_orders ADD COLUMN tax_point DATE;
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 ]
);
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 ]
}
+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;
--- /dev/null
+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;
\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:}
}
\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:}}}
}
}
\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}
<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>
<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>
<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%">
<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>
<p>
<label for="part_id">[% "Article" | $T8 %]</label>
- [% P.part.picker("part_id", "") %]
+ [% P.part.picker("part_id", "", with_makemodel=1) %]
</p>
<p>
<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>
<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 %]
[% 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 %]
<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 %]