1 package SL::Controller::Order;
4 use parent qw(SL::Controller::Base);
6 use SL::Helper::Flash qw(flash_later);
7 use SL::Presenter::Tag qw(select_tag hidden_tag div_tag);
8 use SL::Locale::String qw(t8);
9 use SL::SessionFile::Random;
14 use SL::Util qw(trim);
21 use SL::DB::PartClassification;
22 use SL::DB::PartsGroup;
25 use SL::DB::RecordLink;
27 use SL::DB::Translation;
29 use SL::Helper::CreatePDF qw(:all);
30 use SL::Helper::PrintOptions;
31 use SL::Helper::ShippedQty;
32 use SL::Helper::UserPreferences::PositionsScrollbar;
33 use SL::Helper::UserPreferences::UpdatePositions;
35 use SL::Controller::Helper::GetModels;
37 use List::Util qw(first sum0);
38 use List::UtilsBy qw(sort_by uniq_by);
39 use List::MoreUtils qw(any none pairwise first_index);
40 use English qw(-no_match_vars);
45 use Rose::Object::MakeMethods::Generic
47 scalar => [ qw(item_ids_to_delete is_custom_shipto_to_delete) ],
48 'scalar --get_set_init' => [ qw(order valid_types type cv p all_price_factors search_cvpartnumber show_update_button part_picker_classification_ids) ],
53 __PACKAGE__->run_before('check_auth');
55 __PACKAGE__->run_before('recalc',
56 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
59 __PACKAGE__->run_before('get_unalterable_data',
60 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
71 $self->order->transdate(DateTime->now_local());
72 my $extra_days = $self->{type} eq 'sales_quotation' ? $::instance_conf->get_reqdate_interval :
73 $self->{type} eq 'sales_order' ? $::instance_conf->get_delivery_date_interval : 1;
74 $self->order->reqdate(DateTime->today_local->next_workday(extra_days => $extra_days)) if !$self->order->reqdate;
80 title => $self->get_title_for('add'),
81 %{$self->{template_args}}
85 # edit an existing order
93 # this is to edit an order from an unsaved order object
95 # set item ids to new fake id, to identify them as new items
96 foreach my $item (@{$self->order->items_sorted}) {
97 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
99 # trigger rendering values for second row as hidden, because they
100 # are loaded only on demand. So we need to keep the values from
102 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
109 title => $self->get_title_for('edit'),
110 %{$self->{template_args}}
114 # edit a collective order (consisting of one or more existing orders)
115 sub action_edit_collective {
119 my @multi_ids = map {
120 $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1}
121 } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form;
123 # fall back to add if no ids are given
124 if (scalar @multi_ids == 0) {
129 # fall back to save as new if only one id is given
130 if (scalar @multi_ids == 1) {
131 $self->order(SL::DB::Order->new(id => $multi_ids[0])->load);
132 $self->action_save_as_new();
136 # make new order from given orders
137 my @multi_orders = map { SL::DB::Order->new(id => $_)->load } @multi_ids;
138 $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders;
139 $self->order(SL::DB::Order->new_from_multi(\@multi_orders, sort_sources_by => 'transdate'));
141 $self->action_edit();
148 my $errors = $self->delete();
150 if (scalar @{ $errors }) {
151 $self->js->flash('error', $_) foreach @{ $errors };
152 return $self->js->render();
155 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been deleted')
156 : $self->type eq purchase_order_type() ? $::locale->text('The order has been deleted')
157 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been deleted')
158 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been deleted')
160 flash_later('info', $text);
162 my @redirect_params = (
167 $self->redirect_to(@redirect_params);
174 my $errors = $self->save();
176 if (scalar @{ $errors }) {
177 $self->js->flash('error', $_) foreach @{ $errors };
178 return $self->js->render();
181 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
182 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
183 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
184 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
186 flash_later('info', $text);
188 my @redirect_params = (
191 id => $self->order->id,
194 $self->redirect_to(@redirect_params);
197 # save the order as new document an open it for edit
198 sub action_save_as_new {
201 my $order = $self->order;
204 $self->js->flash('error', t8('This object has not been saved yet.'));
205 return $self->js->render();
208 # load order from db to check if values changed
209 my $saved_order = SL::DB::Order->new(id => $order->id)->load;
212 # Lets assign a new number if the user hasn't changed the previous one.
213 # If it has been changed manually then use it as-is.
214 $new_attrs{number} = (trim($order->number) eq $saved_order->number)
216 : trim($order->number);
218 # Clear transdate unless changed
219 $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
220 ? DateTime->today_local
223 # Set new reqdate unless changed
224 if ($order->reqdate == $saved_order->reqdate) {
225 my $extra_days = $self->{type} eq 'sales_quotation' ? $::instance_conf->get_reqdate_interval :
226 $self->{type} eq 'sales_order' ? $::instance_conf->get_delivery_date_interval : 1;
227 $new_attrs{reqdate} = DateTime->today_local->next_workday(extra_days => $extra_days);
229 $new_attrs{reqdate} = $order->reqdate;
233 $new_attrs{employee} = SL::DB::Manager::Employee->current;
235 # Create new record from current one
236 $self->order(SL::DB::Order->new_from($order, destination_type => $order->type, attributes => \%new_attrs));
238 # no linked records on save as new
239 delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
242 $self->action_save();
247 # This is called if "print" is pressed in the print dialog.
248 # If PDF creation was requested and succeeded, the pdf is offered for download
249 # via send_file (which uses ajax in this case).
253 my $errors = $self->save();
255 if (scalar @{ $errors }) {
256 $self->js->flash('error', $_) foreach @{ $errors };
257 return $self->js->render();
260 $self->js_reset_order_and_item_ids_after_save;
262 my $format = $::form->{print_options}->{format};
263 my $media = $::form->{print_options}->{media};
264 my $formname = $::form->{print_options}->{formname};
265 my $copies = $::form->{print_options}->{copies};
266 my $groupitems = $::form->{print_options}->{groupitems};
267 my $printer_id = $::form->{print_options}->{printer_id};
269 # only pdf and opendocument by now
270 if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf)) {
271 return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
274 # only screen or printer by now
275 if (none { $media eq $_ } qw(screen printer)) {
276 return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
279 # create a form for generate_attachment_filename
280 my $form = Form->new;
281 $form->{$self->nr_key()} = $self->order->number;
282 $form->{type} = $self->type;
283 $form->{format} = $format;
284 $form->{formname} = $formname;
285 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
286 my $pdf_filename = $form->generate_attachment_filename();
289 my @errors = generate_pdf($self->order, \$pdf, { format => $format,
290 formname => $formname,
291 language => $self->order->language,
292 printer_id => $printer_id,
293 groupitems => $groupitems });
294 if (scalar @errors) {
295 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
298 if ($media eq 'screen') {
300 $self->js->flash('info', t8('The PDF has been created'));
303 type => SL::MIME->mime_type_from_ext($pdf_filename),
304 name => $pdf_filename,
308 } elsif ($media eq 'printer') {
310 my $printer_id = $::form->{print_options}->{printer_id};
311 SL::DB::Printer->new(id => $printer_id)->load->print_document(
316 $self->js->flash('info', t8('The PDF has been printed'));
319 # copy file to webdav folder
320 if ($self->order->number && $::instance_conf->get_webdav_documents) {
321 my $webdav = SL::Webdav->new(
323 number => $self->order->number,
325 my $webdav_file = SL::Webdav::File->new(
327 filename => $pdf_filename,
330 $webdav_file->store(data => \$pdf);
333 $self->js->flash('error', t8('Storing PDF to webdav folder failed: #1', $@));
336 if ($self->order->number && $::instance_conf->get_doc_storage) {
338 SL::File->save(object_id => $self->order->id,
339 object_type => $self->type,
340 mime_type => 'application/pdf',
342 file_type => 'document',
343 file_name => $pdf_filename,
344 file_contents => $pdf);
347 $self->js->flash('error', t8('Storing PDF in storage backend failed: #1', $@));
351 $self->save_history('PRINTED');
354 ->run('kivi.ActionBar.setEnabled', '#save_and_email_action')
358 # open the email dialog
359 sub action_save_and_show_email_dialog {
362 my $errors = $self->save();
364 if (scalar @{ $errors }) {
365 $self->js->flash('error', $_) foreach @{ $errors };
366 return $self->js->render();
369 my $cv_method = $self->cv;
371 if (!$self->order->$cv_method) {
372 return $self->js->flash('error', $self->cv eq 'customer' ? t8('Cannot send E-mail without customer given') : t8('Cannot send E-mail without vendor given'))
377 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
378 $email_form->{to} ||= $self->order->$cv_method->email;
379 $email_form->{cc} = $self->order->$cv_method->cc;
380 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
381 # Todo: get addresses from shipto, if any
383 my $form = Form->new;
384 $form->{$self->nr_key()} = $self->order->number;
385 $form->{cusordnumber} = $self->order->cusordnumber;
386 $form->{formname} = $self->type;
387 $form->{type} = $self->type;
388 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
389 $form->{language_id} = $self->order->language->id if $self->order->language;
390 $form->{format} = 'pdf';
392 $email_form->{subject} = $form->generate_email_subject();
393 $email_form->{attachment_filename} = $form->generate_attachment_filename();
394 $email_form->{message} = $form->generate_email_body();
395 $email_form->{js_send_function} = 'kivi.Order.send_email()';
397 my %files = $self->get_files_for_email_dialog();
398 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
399 email_form => $email_form,
400 show_bcc => $::auth->assert('email_bcc', 'may fail'),
402 is_customer => $self->cv eq 'customer',
406 ->run('kivi.Order.show_email_dialog', $dialog_html)
413 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
414 sub action_send_email {
417 my $errors = $self->save();
419 if (scalar @{ $errors }) {
420 $self->js->run('kivi.Order.close_email_dialog');
421 $self->js->flash('error', $_) foreach @{ $errors };
422 return $self->js->render();
425 $self->js_reset_order_and_item_ids_after_save;
427 my $email_form = delete $::form->{email_form};
428 my %field_names = (to => 'email');
430 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
432 # for Form::cleanup which may be called in Form::send_email
433 $::form->{cwd} = getcwd();
434 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
436 $::form->{$_} = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
437 $::form->{media} = 'email';
439 if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) {
441 my @errors = generate_pdf($self->order, \$pdf, {media => $::form->{media},
442 format => $::form->{print_options}->{format},
443 formname => $::form->{print_options}->{formname},
444 language => $self->order->language,
445 printer_id => $::form->{print_options}->{printer_id},
446 groupitems => $::form->{print_options}->{groupitems}});
447 if (scalar @errors) {
448 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
451 my $sfile = SL::SessionFile::Random->new(mode => "w");
452 $sfile->fh->print($pdf);
455 $::form->{tmpfile} = $sfile->file_name;
456 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
459 $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a linked record to the mail
460 $::form->send_email(\%::myconfig, 'pdf');
463 my $intnotes = $self->order->intnotes;
464 $intnotes .= "\n\n" if $self->order->intnotes;
465 $intnotes .= t8('[email]') . "\n";
466 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
467 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
468 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
469 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
470 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
471 $intnotes .= t8('Message') . ": " . $::form->{message};
473 $self->order->update_attributes(intnotes => $intnotes);
475 $self->save_history('MAILED');
477 flash_later('info', t8('The email has been sent.'));
479 my @redirect_params = (
482 id => $self->order->id,
485 $self->redirect_to(@redirect_params);
488 # open the periodic invoices config dialog
490 # If there are values in the form (i.e. dialog was opened before),
491 # then use this values. Create new ones, else.
492 sub action_show_periodic_invoices_config_dialog {
495 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
496 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
497 $config ||= SL::DB::PeriodicInvoicesConfig->new(periodicity => 'm',
498 order_value_periodicity => 'p', # = same as periodicity
499 start_date_as_date => $::form->{transdate_as_date} || $::form->current_date,
500 extend_automatically_by => 12,
502 email_subject => GenericTranslations->get(
503 language_id => $::form->{language_id},
504 translation_type =>"preset_text_periodic_invoices_email_subject"),
505 email_body => GenericTranslations->get(
506 language_id => $::form->{language_id},
507 translation_type =>"preset_text_periodic_invoices_email_body"),
509 $config->periodicity('m') if none { $_ eq $config->periodicity } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
510 $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
512 $::form->get_lists(printers => "ALL_PRINTERS",
513 charts => { key => 'ALL_CHARTS',
514 transdate => 'current_date' });
516 $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
518 if ($::form->{customer_id}) {
519 $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
520 $::form->{email_recipient_invoice_address} = SL::DB::Manager::Customer->find_by(id => $::form->{customer_id})->invoice_mail;
523 $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
525 popup_js_close_function => 'kivi.Order.close_periodic_invoices_config_dialog()',
526 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
531 # assign the values of the periodic invoices config dialog
532 # as yaml in the hidden tag and set the status.
533 sub action_assign_periodic_invoices_config {
536 $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
538 my $config = { active => $::form->{active} ? 1 : 0,
539 terminated => $::form->{terminated} ? 1 : 0,
540 direct_debit => $::form->{direct_debit} ? 1 : 0,
541 periodicity => (any { $_ eq $::form->{periodicity} } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES) ? $::form->{periodicity} : 'm',
542 order_value_periodicity => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
543 start_date_as_date => $::form->{start_date_as_date},
544 end_date_as_date => $::form->{end_date_as_date},
545 first_billing_date_as_date => $::form->{first_billing_date_as_date},
546 print => $::form->{print} ? 1 : 0,
547 printer_id => $::form->{print} ? $::form->{printer_id} * 1 : undef,
548 copies => $::form->{copies} * 1 ? $::form->{copies} : 1,
549 extend_automatically_by => $::form->{extend_automatically_by} * 1 || undef,
550 ar_chart_id => $::form->{ar_chart_id} * 1,
551 send_email => $::form->{send_email} ? 1 : 0,
552 email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
553 email_recipient_address => $::form->{email_recipient_address},
554 email_sender => $::form->{email_sender},
555 email_subject => $::form->{email_subject},
556 email_body => $::form->{email_body},
559 my $periodic_invoices_config = SL::YAML::Dump($config);
561 my $status = $self->get_periodic_invoices_status($config);
564 ->remove('#order_periodic_invoices_config')
565 ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
566 ->run('kivi.Order.close_periodic_invoices_config_dialog')
567 ->html('#periodic_invoices_status', $status)
568 ->flash('info', t8('The periodic invoices config has been assigned.'))
572 sub action_get_has_active_periodic_invoices {
575 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
576 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
578 my $has_active_periodic_invoices =
579 $self->type eq sales_order_type()
582 && (!$config->end_date || ($config->end_date > DateTime->today_local))
583 && $config->get_previous_billed_period_start_date;
585 $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
588 # save the order and redirect to the frontend subroutine for a new
590 sub action_save_and_delivery_order {
593 $self->save_and_redirect_to(
594 controller => 'oe.pl',
595 action => 'oe_delivery_order_from_order',
599 # save the order and redirect to the frontend subroutine for a new
601 sub action_save_and_invoice {
604 $self->save_and_redirect_to(
605 controller => 'oe.pl',
606 action => 'oe_invoice_from_order',
610 # workflow from sales order to sales quotation
611 sub action_sales_quotation {
612 $_[0]->workflow_sales_or_request_for_quotation();
615 # workflow from sales order to sales quotation
616 sub action_request_for_quotation {
617 $_[0]->workflow_sales_or_request_for_quotation();
620 # workflow from sales quotation to sales order
621 sub action_sales_order {
622 $_[0]->workflow_sales_or_purchase_order();
625 # workflow from rfq to purchase order
626 sub action_purchase_order {
627 $_[0]->workflow_sales_or_purchase_order();
630 # workflow from purchase order to ap transaction
631 sub action_save_and_ap_transaction {
634 $self->save_and_redirect_to(
635 controller => 'ap.pl',
636 action => 'add_from_purchase_order',
640 # set form elements in respect to a changed customer or vendor
642 # This action is called on an change of the customer/vendor picker.
643 sub action_customer_vendor_changed {
646 setup_order_from_cv($self->order);
649 my $cv_method = $self->cv;
651 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
652 $self->js->show('#cp_row');
654 $self->js->hide('#cp_row');
657 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
658 $self->js->show('#shipto_selection');
660 $self->js->hide('#shipto_selection');
663 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
666 ->replaceWith('#order_cp_id', $self->build_contact_select)
667 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
668 ->replaceWith('#shipto_inputs ', $self->build_shipto_inputs)
669 ->replaceWith('#business_info_row', $self->build_business_info_row)
670 ->val( '#order_taxzone_id', $self->order->taxzone_id)
671 ->val( '#order_taxincluded', $self->order->taxincluded)
672 ->val( '#order_currency_id', $self->order->currency_id)
673 ->val( '#order_payment_id', $self->order->payment_id)
674 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
675 ->val( '#order_intnotes', $self->order->intnotes)
676 ->val( '#order_language_id', $self->order->$cv_method->language_id)
677 ->focus( '#order_' . $self->cv . '_id')
678 ->run('kivi.Order.update_exchangerate');
680 $self->js_redisplay_amounts_and_taxes;
681 $self->js_redisplay_cvpartnumbers;
685 # open the dialog for customer/vendor details
686 sub action_show_customer_vendor_details_dialog {
689 my $is_customer = 'customer' eq $::form->{vc};
692 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
694 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
697 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
698 $details{discount_as_percent} = $cv->discount_as_percent;
699 $details{creditlimt} = $cv->creditlimit_as_number;
700 $details{business} = $cv->business->description if $cv->business;
701 $details{language} = $cv->language_obj->description if $cv->language_obj;
702 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
703 $details{payment_terms} = $cv->payment->description if $cv->payment;
704 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
706 foreach my $entry (@{ $cv->shipto }) {
707 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
709 foreach my $entry (@{ $cv->contacts }) {
710 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
713 $_[0]->render('common/show_vc_details', { layout => 0 },
714 is_customer => $is_customer,
719 # called if a unit in an existing item row is changed
720 sub action_unit_changed {
723 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
724 my $item = $self->order->items_sorted->[$idx];
726 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
727 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
732 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
733 $self->js_redisplay_line_values;
734 $self->js_redisplay_amounts_and_taxes;
738 # add an item row for a new item entered in the input row
739 sub action_add_item {
742 my $form_attr = $::form->{add_item};
744 return unless $form_attr->{parts_id};
746 my $item = new_item($self->order, $form_attr);
748 $self->order->add_items($item);
752 $self->get_item_cvpartnumber($item);
754 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
755 my $row_as_html = $self->p->render('order/tabs/_row',
761 if ($::form->{insert_before_item_id}) {
763 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
766 ->append('#row_table_id', $row_as_html);
769 if ( $item->part->is_assortment ) {
770 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
771 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
772 my $attr = { parts_id => $assortment_item->parts_id,
773 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
774 unit => $assortment_item->unit,
775 description => $assortment_item->part->description,
777 my $item = new_item($self->order, $attr);
779 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
780 $item->discount(1) unless $assortment_item->charge;
782 $self->order->add_items( $item );
784 $self->get_item_cvpartnumber($item);
785 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
786 my $row_as_html = $self->p->render('order/tabs/_row',
791 if ($::form->{insert_before_item_id}) {
793 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
796 ->append('#row_table_id', $row_as_html);
802 ->val('.add_item_input', '')
803 ->run('kivi.Order.init_row_handlers')
804 ->run('kivi.Order.renumber_positions')
805 ->focus('#add_item_parts_id_name');
807 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
809 $self->js_redisplay_amounts_and_taxes;
813 # add item rows for multiple items at once
814 sub action_add_multi_items {
817 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
818 return $self->js->render() unless scalar @form_attr;
821 foreach my $attr (@form_attr) {
822 my $item = new_item($self->order, $attr);
824 if ( $item->part->is_assortment ) {
825 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
826 my $attr = { parts_id => $assortment_item->parts_id,
827 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
828 unit => $assortment_item->unit,
829 description => $assortment_item->part->description,
831 my $item = new_item($self->order, $attr);
833 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
834 $item->discount(1) unless $assortment_item->charge;
839 $self->order->add_items(@items);
843 foreach my $item (@items) {
844 $self->get_item_cvpartnumber($item);
845 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
846 my $row_as_html = $self->p->render('order/tabs/_row',
852 if ($::form->{insert_before_item_id}) {
854 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
857 ->append('#row_table_id', $row_as_html);
862 ->run('kivi.Part.close_picker_dialogs')
863 ->run('kivi.Order.init_row_handlers')
864 ->run('kivi.Order.renumber_positions')
865 ->focus('#add_item_parts_id_name');
867 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
869 $self->js_redisplay_amounts_and_taxes;
873 # recalculate all linetotals, amounts and taxes and redisplay them
874 sub action_recalc_amounts_and_taxes {
879 $self->js_redisplay_line_values;
880 $self->js_redisplay_amounts_and_taxes;
884 sub action_update_exchangerate {
888 is_standard => $self->order->currency_id == $::instance_conf->get_currency_id,
889 currency_name => $self->order->currency->name,
890 exchangerate => $self->order->daily_exchangerate_as_null_number,
893 $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
896 # redisplay item rows if they are sorted by an attribute
897 sub action_reorder_items {
901 partnumber => sub { $_[0]->part->partnumber },
902 description => sub { $_[0]->description },
903 qty => sub { $_[0]->qty },
904 sellprice => sub { $_[0]->sellprice },
905 discount => sub { $_[0]->discount },
906 cvpartnumber => sub { $_[0]->{cvpartnumber} },
909 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
911 my $method = $sort_keys{$::form->{order_by}};
912 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
913 if ($::form->{sort_dir}) {
914 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
915 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
917 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
920 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
921 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
923 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
927 ->run('kivi.Order.redisplay_items', \@to_sort)
931 # show the popup to choose a price/discount source
932 sub action_price_popup {
935 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
936 my $item = $self->order->items_sorted->[$idx];
938 $self->render_price_dialog($item);
941 # load the second row for one or more items
943 # This action gets the html code for all items second rows by rendering a template for
944 # the second row and sets the html code via client js.
945 sub action_load_second_rows {
948 $self->recalc() if $self->order->is_sales; # for margin calculation
950 foreach my $item_id (@{ $::form->{item_ids} }) {
951 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
952 my $item = $self->order->items_sorted->[$idx];
954 $self->js_load_second_row($item, $item_id, 0);
957 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
962 # update description, notes and sellprice from master data
963 sub action_update_row_from_master_data {
966 foreach my $item_id (@{ $::form->{item_ids} }) {
967 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
968 my $item = $self->order->items_sorted->[$idx];
969 my $texts = get_part_texts($item->part, $self->order->language_id);
971 $item->description($texts->{description});
972 $item->longdescription($texts->{longdescription});
974 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
977 if ($item->part->is_assortment) {
978 # add assortment items with price 0, as the components carry the price
979 $price_src = $price_source->price_from_source("");
980 $price_src->price(0);
982 $price_src = $price_source->best_price
983 ? $price_source->best_price
984 : $price_source->price_from_source("");
985 $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate;
986 $price_src->price(0) if !$price_source->best_price;
990 $item->sellprice($price_src->price);
991 $item->active_price_source($price_src);
994 ->run('kivi.Order.update_sellprice', $item_id, $item->sellprice_as_number)
995 ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
996 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
997 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
999 if ($self->search_cvpartnumber) {
1000 $self->get_item_cvpartnumber($item);
1001 $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
1006 $self->js_redisplay_line_values;
1007 $self->js_redisplay_amounts_and_taxes;
1009 $self->js->render();
1012 sub js_load_second_row {
1013 my ($self, $item, $item_id, $do_parse) = @_;
1016 # Parse values from form (they are formated while rendering (template)).
1017 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1018 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1019 foreach my $var (@{ $item->cvars_by_config }) {
1020 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1022 $item->parse_custom_variable_values;
1025 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1028 ->html('#second_row_' . $item_id, $row_as_html)
1029 ->data('#second_row_' . $item_id, 'loaded', 1);
1032 sub js_redisplay_line_values {
1035 my $is_sales = $self->order->is_sales;
1037 # sales orders with margins
1042 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1043 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1044 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1045 ]} @{ $self->order->items_sorted };
1049 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1050 ]} @{ $self->order->items_sorted };
1054 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1057 sub js_redisplay_amounts_and_taxes {
1060 if (scalar @{ $self->{taxes} }) {
1061 $self->js->show('#taxincluded_row_id');
1063 $self->js->hide('#taxincluded_row_id');
1066 if ($self->order->taxincluded) {
1067 $self->js->hide('#subtotal_row_id');
1069 $self->js->show('#subtotal_row_id');
1072 if ($self->order->is_sales) {
1073 my $is_neg = $self->order->marge_total < 0;
1075 ->html('#marge_total_id', $::form->format_amount(\%::myconfig, $self->order->marge_total, 2))
1076 ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
1077 ->action_if( $is_neg, 'addClass', '#marge_total_id', 'plus0')
1078 ->action_if( $is_neg, 'addClass', '#marge_percent_id', 'plus0')
1079 ->action_if( $is_neg, 'addClass', '#marge_percent_sign_id', 'plus0')
1080 ->action_if(!$is_neg, 'removeClass', '#marge_total_id', 'plus0')
1081 ->action_if(!$is_neg, 'removeClass', '#marge_percent_id', 'plus0')
1082 ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
1086 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
1087 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
1088 ->remove('.tax_row')
1089 ->insertBefore($self->build_tax_rows, '#amount_row_id');
1092 sub js_redisplay_cvpartnumbers {
1095 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1097 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1100 ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
1103 sub js_reset_order_and_item_ids_after_save {
1107 ->val('#id', $self->order->id)
1108 ->val('#converted_from_oe_id', '')
1109 ->val('#order_' . $self->nr_key(), $self->order->number);
1112 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1113 next if !$self->order->items_sorted->[$idx]->id;
1114 next if $form_item_id !~ m{^new};
1116 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1117 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1118 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1122 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1129 sub init_valid_types {
1130 [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
1136 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1137 die "Not a valid type for order";
1140 $self->type($::form->{type});
1146 my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer'
1147 : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
1148 : die "Not a valid type for order";
1153 sub init_search_cvpartnumber {
1156 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1157 my $search_cvpartnumber;
1158 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1159 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1161 return $search_cvpartnumber;
1164 sub init_show_update_button {
1167 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1178 sub init_all_price_factors {
1179 SL::DB::Manager::PriceFactor->get_all;
1182 sub init_part_picker_classification_ids {
1184 my $attribute = 'used_for_' . ($self->type =~ m{sales} ? 'sale' : 'purchase');
1186 return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => [ $attribute => 1 ]) } ];
1192 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
1194 my $right = $right_for->{ $self->type };
1195 $right ||= 'DOES_NOT_EXIST';
1197 $::auth->assert($right);
1200 # build the selection box for contacts
1202 # Needed, if customer/vendor changed.
1203 sub build_contact_select {
1206 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1207 value_key => 'cp_id',
1208 title_key => 'full_name_dep',
1209 default => $self->order->cp_id,
1211 style => 'width: 300px',
1215 # build the selection box for shiptos
1217 # Needed, if customer/vendor changed.
1218 sub build_shipto_select {
1221 select_tag('order.shipto_id',
1222 [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
1223 value_key => 'shipto_id',
1224 title_key => 'displayable_id',
1225 default => $self->order->shipto_id,
1227 style => 'width: 300px',
1231 # build the inputs for the cusom shipto dialog
1233 # Needed, if customer/vendor changed.
1234 sub build_shipto_inputs {
1237 my $content = $self->p->render('common/_ship_to_dialog',
1238 vc_obj => $self->order->customervendor,
1239 cs_obj => $self->order->custom_shipto,
1240 cvars => $self->order->custom_shipto->cvars_by_config,
1241 id_selector => '#order_shipto_id');
1243 div_tag($content, id => 'shipto_inputs');
1246 # render the info line for business
1248 # Needed, if customer/vendor changed.
1249 sub build_business_info_row
1251 $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
1254 # build the rows for displaying taxes
1256 # Called if amounts where recalculated and redisplayed.
1257 sub build_tax_rows {
1261 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
1262 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
1264 return $rows_as_html;
1268 sub render_price_dialog {
1269 my ($self, $record_item) = @_;
1271 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1275 'kivi.io.price_chooser_dialog',
1276 t8('Available Prices'),
1277 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1282 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1283 # $self->js->show('#dialog_flash_error');
1292 return if !$::form->{id};
1294 $self->order(SL::DB::Order->new(id => $::form->{id})->load);
1296 # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
1297 # You need a custom shipto object to call cvars_by_config to get the cvars.
1298 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
1300 return $self->order;
1303 # load or create a new order object
1305 # And assign changes from the form to this object.
1306 # If the order is loaded from db, check if items are deleted in the form,
1307 # remove them form the object and collect them for removing from db on saving.
1308 # Then create/update items from form (via make_item) and add them.
1312 # add_items adds items to an order with no items for saving, but they cannot
1313 # be retrieved via items until the order is saved. Adding empty items to new
1314 # order here solves this problem.
1316 $order = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1317 $order ||= SL::DB::Order->new(orderitems => [],
1318 quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())),
1319 currency_id => $::instance_conf->get_currency_id(),);
1321 my $cv_id_method = $self->cv . '_id';
1322 if (!$::form->{id} && $::form->{$cv_id_method}) {
1323 $order->$cv_id_method($::form->{$cv_id_method});
1324 setup_order_from_cv($order);
1327 my $form_orderitems = delete $::form->{order}->{orderitems};
1328 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1330 $order->assign_attributes(%{$::form->{order}});
1332 $self->setup_custom_shipto_from_form($order, $::form);
1334 if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) {
1335 my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
1336 $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
1339 # remove deleted items
1340 $self->item_ids_to_delete([]);
1341 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1342 my $item = $order->orderitems->[$idx];
1343 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1344 splice @{$order->orderitems}, $idx, 1;
1345 push @{$self->item_ids_to_delete}, $item->id;
1351 foreach my $form_attr (@{$form_orderitems}) {
1352 my $item = make_item($order, $form_attr);
1353 $item->position($pos);
1357 $order->add_items(grep {!$_->id} @items);
1362 # create or update items from form
1364 # Make item objects from form values. For items already existing read from db.
1365 # Create a new item else. And assign attributes.
1367 my ($record, $attr) = @_;
1370 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1372 my $is_new = !$item;
1374 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1375 # they cannot be retrieved via custom_variables until the order/orderitem is
1376 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1377 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1379 $item->assign_attributes(%$attr);
1382 my $texts = get_part_texts($item->part, $record->language_id);
1383 $item->longdescription($texts->{longdescription}) if !defined $attr->{longdescription};
1384 $item->project_id($record->globalproject_id) if !defined $attr->{project_id};
1385 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number};
1393 # This is used to add one item
1395 my ($record, $attr) = @_;
1397 my $item = SL::DB::OrderItem->new;
1399 # Remove attributes where the user left or set the inputs empty.
1400 # So these attributes will be undefined and we can distinguish them
1401 # from zero later on.
1402 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1403 delete $attr->{$_} if $attr->{$_} eq '';
1406 $item->assign_attributes(%$attr);
1408 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1409 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1411 $item->unit($part->unit) if !$item->unit;
1414 if ( $part->is_assortment ) {
1415 # add assortment items with price 0, as the components carry the price
1416 $price_src = $price_source->price_from_source("");
1417 $price_src->price(0);
1418 } elsif (defined $item->sellprice) {
1419 $price_src = $price_source->price_from_source("");
1420 $price_src->price($item->sellprice);
1422 $price_src = $price_source->best_price
1423 ? $price_source->best_price
1424 : $price_source->price_from_source("");
1425 $price_src->price($::form->round_amount($price_src->price / $record->exchangerate, 5)) if $record->exchangerate;
1426 $price_src->price(0) if !$price_source->best_price;
1430 if (defined $item->discount) {
1431 $discount_src = $price_source->discount_from_source("");
1432 $discount_src->discount($item->discount);
1434 $discount_src = $price_source->best_discount
1435 ? $price_source->best_discount
1436 : $price_source->discount_from_source("");
1437 $discount_src->discount(0) if !$price_source->best_discount;
1441 $new_attr{part} = $part;
1442 $new_attr{description} = $part->description if ! $item->description;
1443 $new_attr{qty} = 1.0 if ! $item->qty;
1444 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1445 $new_attr{sellprice} = $price_src->price;
1446 $new_attr{discount} = $discount_src->discount;
1447 $new_attr{active_price_source} = $price_src;
1448 $new_attr{active_discount_source} = $discount_src;
1449 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1450 $new_attr{project_id} = $record->globalproject_id;
1451 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1453 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1454 # they cannot be retrieved via custom_variables until the order/orderitem is
1455 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1456 $new_attr{custom_variables} = [];
1458 my $texts = get_part_texts($part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription});
1460 $item->assign_attributes(%new_attr, %{ $texts });
1465 sub setup_order_from_cv {
1468 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id));
1470 $order->intnotes($order->customervendor->notes);
1472 if ($order->is_sales) {
1473 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1474 $order->taxincluded(defined($order->customer->taxincluded_checked)
1475 ? $order->customer->taxincluded_checked
1476 : $::myconfig{taxincluded_checked});
1481 # setup custom shipto from form
1483 # The dialog returns form variables starting with 'shipto' and cvars starting
1484 # with 'shiptocvar_'.
1485 # Mark it to be deleted if a shipto from master data is selected
1486 # (i.e. order has a shipto).
1487 # Else, update or create a new custom shipto. If the fields are empty, it
1488 # will not be saved on save.
1489 sub setup_custom_shipto_from_form {
1490 my ($self, $order, $form) = @_;
1492 if ($order->shipto) {
1493 $self->is_custom_shipto_to_delete(1);
1495 my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1497 my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
1498 my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
1500 $custom_shipto->assign_attributes(%$shipto_attrs);
1501 $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
1505 # recalculate prices and taxes
1507 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1511 my %pat = $self->order->calculate_prices_and_taxes();
1513 $self->{taxes} = [];
1514 foreach my $tax_id (keys %{ $pat{taxes_by_tax_id} }) {
1515 my $netamount = sum0 map { $pat{amounts}->{$_}->{amount} } grep { $pat{amounts}->{$_}->{tax_id} == $tax_id } keys %{ $pat{amounts} };
1517 push(@{ $self->{taxes} }, { amount => $pat{taxes_by_tax_id}->{$tax_id},
1518 netamount => $netamount,
1519 tax => SL::DB::Tax->new(id => $tax_id)->load });
1521 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items_sorted}, @{$pat{items}};
1524 # get data for saving, printing, ..., that is not changed in the form
1526 # Only cvars for now.
1527 sub get_unalterable_data {
1530 foreach my $item (@{ $self->order->items }) {
1531 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1532 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1533 foreach my $var (@{ $item->cvars_by_config }) {
1534 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1536 $item->parse_custom_variable_values;
1542 # And remove related files in the spool directory
1547 my $db = $self->order->db;
1549 $db->with_transaction(
1551 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1552 $self->order->delete;
1553 my $spool = $::lx_office_conf{paths}->{spool};
1554 unlink map { "$spool/$_" } @spoolfiles if $spool;
1556 $self->save_history('DELETED');
1559 }) || push(@{$errors}, $db->error);
1566 # And delete items that are deleted in the form.
1571 my $db = $self->order->db;
1573 $db->with_transaction(sub {
1574 # delete custom shipto if it is to be deleted or if it is empty
1575 if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
1576 $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
1577 $self->order->custom_shipto(undef);
1580 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1581 $self->order->save(cascade => 1);
1584 if ($::form->{converted_from_oe_id}) {
1585 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1586 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1587 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1588 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
1589 $src->link_to_record($self->order);
1591 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1593 foreach (@{ $self->order->items_sorted }) {
1594 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1596 SL::DB::RecordLink->new(from_table => 'orderitems',
1597 from_id => $from_id,
1598 to_table => 'orderitems',
1606 $self->save_history('SAVED');
1609 }) || push(@{$errors}, $db->error);
1614 sub workflow_sales_or_request_for_quotation {
1618 my $errors = $self->save();
1620 if (scalar @{ $errors }) {
1621 $self->js->flash('error', $_) for @{ $errors };
1622 return $self->js->render();
1625 my $destination_type = $::form->{type} eq sales_order_type() ? sales_quotation_type() : request_quotation_type();
1627 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1628 $self->{converted_from_oe_id} = delete $::form->{id};
1630 # set item ids to new fake id, to identify them as new items
1631 foreach my $item (@{$self->order->items_sorted}) {
1632 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1636 $::form->{type} = $destination_type;
1637 $self->type($self->init_type);
1638 $self->cv ($self->init_cv);
1642 $self->get_unalterable_data();
1643 $self->pre_render();
1645 # trigger rendering values for second row as hidden, because they
1646 # are loaded only on demand. So we need to keep the values from the
1648 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1652 title => $self->get_title_for('edit'),
1653 %{$self->{template_args}}
1657 sub workflow_sales_or_purchase_order {
1661 my $errors = $self->save();
1663 if (scalar @{ $errors }) {
1664 $self->js->flash('error', $_) foreach @{ $errors };
1665 return $self->js->render();
1668 my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type()
1669 : $::form->{type} eq request_quotation_type() ? purchase_order_type()
1670 : $::form->{type} eq purchase_order_type() ? sales_order_type()
1671 : $::form->{type} eq sales_order_type() ? purchase_order_type()
1674 # check for direct delivery
1675 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
1677 if ( $::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()
1678 && $::form->{use_shipto} && $self->order->shipto) {
1679 $custom_shipto = $self->order->shipto->clone('SL::DB::Order');
1682 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1683 $self->{converted_from_oe_id} = delete $::form->{id};
1685 # set item ids to new fake id, to identify them as new items
1686 foreach my $item (@{$self->order->items_sorted}) {
1687 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1690 if ($::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()) {
1691 if ($::form->{use_shipto}) {
1692 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
1694 # remove any custom shipto if not wanted
1695 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1700 $::form->{type} = $destination_type;
1701 $self->type($self->init_type);
1702 $self->cv ($self->init_cv);
1706 $self->get_unalterable_data();
1707 $self->pre_render();
1709 # trigger rendering values for second row as hidden, because they
1710 # are loaded only on demand. So we need to keep the values from the
1712 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1716 title => $self->get_title_for('edit'),
1717 %{$self->{template_args}}
1725 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1726 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
1727 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1728 $self->{all_languages} = SL::DB::Manager::Language->get_all_sorted();
1729 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1732 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1735 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1737 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1738 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1739 $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
1740 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1741 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1743 my $print_form = Form->new('');
1744 $print_form->{type} = $self->type;
1745 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1746 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1747 form => $print_form,
1748 options => {dialog_name_prefix => 'print_options.',
1752 no_opendocument => 0,
1756 foreach my $item (@{$self->order->orderitems}) {
1757 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1758 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1759 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1762 if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
1763 # calculate shipped qtys here to prevent calling calculate for every item via the items method
1764 SL::Helper::ShippedQty->new->calculate($self->order)->write_to_objects;
1767 if ($self->order->number && $::instance_conf->get_webdav) {
1768 my $webdav = SL::Webdav->new(
1769 type => $self->type,
1770 number => $self->order->number,
1772 my @all_objects = $webdav->get_all_objects;
1773 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1775 link => File::Spec->catfile($_->full_filedescriptor),
1779 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1781 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
1782 edit_periodic_invoices_config calculate_qty kivi.Validator follow_up show_history);
1783 $self->setup_edit_action_bar;
1786 sub setup_edit_action_bar {
1787 my ($self, %params) = @_;
1789 my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
1790 || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
1791 || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
1793 for my $bar ($::request->layout->get('actionbar')) {
1798 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1799 $::instance_conf->get_order_warn_no_deliverydate,
1801 checks => [ 'kivi.Order.check_save_active_periodic_invoices', ['kivi.validate_form','#order_form'] ],
1805 call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1806 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1807 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1809 ], # end of combobox "Save"
1816 t8('Save and Quotation'),
1817 submit => [ '#order_form', { action => "Order/sales_quotation" } ],
1818 only_if => (any { $self->type eq $_ } (sales_order_type())),
1822 submit => [ '#order_form', { action => "Order/request_for_quotation" } ],
1823 only_if => (any { $self->type eq $_ } (purchase_order_type())),
1826 t8('Save and Sales Order'),
1827 submit => [ '#order_form', { action => "Order/sales_order" } ],
1828 only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
1831 t8('Save and Purchase Order'),
1832 call => [ 'kivi.Order.purchase_order_check_for_direct_delivery' ],
1833 only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
1836 t8('Save and Delivery Order'),
1837 call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1838 $::instance_conf->get_order_warn_no_deliverydate,
1840 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1841 only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type()))
1844 t8('Save and Invoice'),
1845 call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1846 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1849 t8('Save and AP Transaction'),
1850 call => [ 'kivi.Order.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
1851 only_if => (any { $self->type eq $_ } (purchase_order_type()))
1854 ], # end of combobox "Workflow"
1861 t8('Save and print'),
1862 call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts ],
1865 t8('Save and E-mail'),
1866 id => 'save_and_email_action',
1867 call => [ 'kivi.Order.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts ],
1868 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1871 t8('Download attachments of all parts'),
1872 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1873 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1874 only_if => $::instance_conf->get_doc_storage,
1876 ], # end of combobox "Export"
1880 call => [ 'kivi.Order.delete_order' ],
1881 confirm => $::locale->text('Do you really want to delete this object?'),
1882 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1883 only_if => $deletion_allowed,
1892 call => [ 'kivi.Order.follow_up_window' ],
1893 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1894 only_if => $::auth->assert('productivity', 1),
1898 call => [ 'set_history_window', $self->order->id, 'id' ],
1899 disabled => !$self->order->id ? t8('This record has not been saved yet.') : undef,
1901 ], # end of combobox "more"
1907 my ($order, $pdf_ref, $params) = @_;
1911 my $print_form = Form->new('');
1912 $print_form->{type} = $order->type;
1913 $print_form->{formname} = $params->{formname} || $order->type;
1914 $print_form->{format} = $params->{format} || 'pdf';
1915 $print_form->{media} = $params->{media} || 'file';
1916 $print_form->{groupitems} = $params->{groupitems};
1917 $print_form->{printer_id} = $params->{printer_id};
1918 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1920 $order->language($params->{language});
1921 $order->flatten_to_form($print_form, format_amounts => 1);
1925 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
1926 $template_ext = 'odt';
1927 $template_type = 'OpenDocument';
1930 # search for the template
1931 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1932 name => $print_form->{formname},
1933 extension => $template_ext,
1934 email => $print_form->{media} eq 'email',
1935 language => $params->{language},
1936 printer_id => $print_form->{printer_id},
1939 if (!defined $template_file) {
1940 push @errors, $::locale->text('Cannot find matching template for this print request. Please contact your template maintainer. I tried these: #1.', join ', ', map { "'$_'"} @template_files);
1943 return @errors if scalar @errors;
1945 $print_form->throw_on_error(sub {
1947 $print_form->prepare_for_printing;
1949 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1950 format => $print_form->{format},
1951 template_type => $template_type,
1952 template => $template_file,
1953 variables => $print_form,
1954 variable_content_types => {
1955 longdescription => 'html',
1956 partnotes => 'html',
1961 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
1967 sub get_files_for_email_dialog {
1970 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
1972 return %files if !$::instance_conf->get_doc_storage;
1974 if ($self->order->id) {
1975 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
1976 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
1977 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
1981 uniq_by { $_->{id} }
1983 +{ id => $_->part->id,
1984 partnumber => $_->part->partnumber }
1985 } @{$self->order->items_sorted};
1987 foreach my $part (@parts) {
1988 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
1989 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
1992 foreach my $key (keys %files) {
1993 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
1999 sub make_periodic_invoices_config_from_yaml {
2000 my ($yaml_config) = @_;
2002 return if !$yaml_config;
2003 my $attr = SL::YAML::Load($yaml_config);
2004 return if 'HASH' ne ref $attr;
2005 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
2009 sub get_periodic_invoices_status {
2010 my ($self, $config) = @_;
2012 return if $self->type ne sales_order_type();
2013 return t8('not configured') if !$config;
2015 my $active = ('HASH' eq ref $config) ? $config->{active}
2016 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
2017 : die "Cannot get status of periodic invoices config";
2019 return $active ? t8('active') : t8('inactive');
2023 my ($self, $action) = @_;
2025 return '' if none { lc($action)} qw(add edit);
2028 # $::locale->text("Add Sales Order");
2029 # $::locale->text("Add Purchase Order");
2030 # $::locale->text("Add Quotation");
2031 # $::locale->text("Add Request for Quotation");
2032 # $::locale->text("Edit Sales Order");
2033 # $::locale->text("Edit Purchase Order");
2034 # $::locale->text("Edit Quotation");
2035 # $::locale->text("Edit Request for Quotation");
2037 $action = ucfirst(lc($action));
2038 return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order")
2039 : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order")
2040 : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation")
2041 : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
2045 sub get_item_cvpartnumber {
2046 my ($self, $item) = @_;
2048 return if !$self->search_cvpartnumber;
2049 return if !$self->order->customervendor;
2051 if ($self->cv eq 'vendor') {
2052 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
2053 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
2054 } elsif ($self->cv eq 'customer') {
2055 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
2056 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
2060 sub get_part_texts {
2061 my ($part_or_id, $language_or_id, %defaults) = @_;
2063 my $part = ref($part_or_id) ? $part_or_id : SL::DB::Part->load_cached($part_or_id);
2064 my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id;
2066 description => $defaults{description} // $part->description,
2067 longdescription => $defaults{longdescription} // $part->notes,
2070 return $texts unless $language_id;
2072 my $translation = SL::DB::Manager::Translation->get_first(
2074 parts_id => $part->id,
2075 language_id => $language_id,
2078 $texts->{description} = $translation->translation if $translation && $translation->translation;
2079 $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription;
2084 sub sales_order_type {
2088 sub purchase_order_type {
2092 sub sales_quotation_type {
2096 sub request_quotation_type {
2097 'request_quotation';
2101 return $_[0]->type eq sales_order_type() ? 'ordnumber'
2102 : $_[0]->type eq purchase_order_type() ? 'ordnumber'
2103 : $_[0]->type eq sales_quotation_type() ? 'quonumber'
2104 : $_[0]->type eq request_quotation_type() ? 'quonumber'
2108 sub save_and_redirect_to {
2109 my ($self, %params) = @_;
2111 my $errors = $self->save();
2113 if (scalar @{ $errors }) {
2114 $self->js->flash('error', $_) foreach @{ $errors };
2115 return $self->js->render();
2118 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
2119 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
2120 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
2121 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
2123 flash_later('info', $text);
2125 $self->redirect_to(%params, id => $self->order->id);
2129 my ($self, $addition) = @_;
2131 my $number_type = $self->order->type =~ m{order} ? 'ordnumber' : 'quonumber';
2132 my $snumbers = $number_type . '_' . $self->order->$number_type;
2134 SL::DB::History->new(
2135 trans_id => $self->order->id,
2136 employee_id => SL::DB::Manager::Employee->current->id,
2137 what_done => $self->order->type,
2138 snumbers => $snumbers,
2139 addition => $addition,
2151 SL::Controller::Order - controller for orders
2155 This is a new form to enter orders, completely rewritten with the use
2156 of controller and java script techniques.
2158 The aim is to provide the user a better experience and a faster workflow. Also
2159 the code should be more readable, more reliable and better to maintain.
2167 One input row, so that input happens every time at the same place.
2171 Use of pickers where possible.
2175 Possibility to enter more than one item at once.
2179 Item list in a scrollable area, so that the workflow buttons stay at
2184 Reordering item rows with drag and drop is possible. Sorting item rows is
2185 possible (by partnumber, description, qty, sellprice and discount for now).
2189 No C<update> is necessary. All entries and calculations are managed
2190 with ajax-calls and the page only reloads on C<save>.
2194 User can see changes immediately, because of the use of java script
2205 =item * C<SL/Controller/Order.pm>
2209 =item * C<template/webpages/order/form.html>
2213 =item * C<template/webpages/order/tabs/basic_data.html>
2215 Main tab for basic_data.
2217 This is the only tab here for now. "linked records" and "webdav" tabs are
2218 reused from generic code.
2222 =item * C<template/webpages/order/tabs/_business_info_row.html>
2224 For displaying information on business type
2226 =item * C<template/webpages/order/tabs/_item_input.html>
2228 The input line for items
2230 =item * C<template/webpages/order/tabs/_row.html>
2232 One row for already entered items
2234 =item * C<template/webpages/order/tabs/_tax_row.html>
2236 Displaying tax information
2238 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
2240 Dialog for selecting price and discount sources
2244 =item * C<js/kivi.Order.js>
2246 java script functions
2256 =item * price sources: little symbols showing better price / better discount
2258 =item * select units in input row?
2260 =item * check for direct delivery (workflow sales order -> purchase order)
2262 =item * access rights
2264 =item * display weights
2268 =item * optional client/user behaviour
2270 (transactions has to be set - department has to be set -
2271 force project if enabled in client config - transport cost reminder)
2275 =head1 KNOWN BUGS AND CAVEATS
2281 Customer discount is not displayed as a valid discount in price source popup
2282 (this might be a bug in price sources)
2284 (I cannot reproduce this (Bernd))
2288 No indication that <shift>-up/down expands/collapses second row.
2292 Inline creation of parts is not currently supported
2296 Table header is not sticky in the scrolling area.
2300 Sorting does not include C<position>, neither does reordering.
2302 This behavior was implemented intentionally. But we can discuss, which behavior
2303 should be implemented.
2307 =head1 To discuss / Nice to have
2313 How to expand/collapse second row. Now it can be done clicking the icon or
2318 Possibility to select PriceSources in input row?
2322 This controller uses a (changed) copy of the template for the PriceSource
2323 dialog. Maybe there could be used one code source.
2327 Rounding-differences between this controller (PriceTaxCalculator) and the old
2328 form. This is not only a problem here, but also in all parts using the PTC.
2329 There exists a ticket and a patch. This patch should be testet.
2333 An indicator, if the actual inputs are saved (like in an
2334 editor or on text processing application).
2338 A warning when leaving the page without saveing unchanged inputs.
2345 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>