1 package SL::Controller::DeliveryOrder;
4 use parent qw(SL::Controller::Base);
6 use SL::Helper::Flash qw(flash_later);
7 use SL::Helper::Number qw(_format_number _parse_number);
8 use SL::Presenter::Tag qw(select_tag hidden_tag div_tag);
9 use SL::Presenter::DeliveryOrder qw(delivery_order_status_line);
10 use SL::Locale::String qw(t8);
11 use SL::SessionFile::Random;
16 use SL::Util qw(trim);
24 use SL::DB::PartClassification;
25 use SL::DB::PartsGroup;
28 use SL::DB::RecordLink;
30 use SL::DB::Translation;
31 use SL::DB::TransferType;
33 use SL::Helper::CreatePDF qw(:all);
34 use SL::Helper::PrintOptions;
35 use SL::Helper::ShippedQty;
36 use SL::Helper::UserPreferences::PositionsScrollbar;
37 use SL::Helper::UserPreferences::UpdatePositions;
39 use SL::Controller::Helper::GetModels;
40 use SL::Controller::DeliveryOrder::TypeData qw(:types);
42 use List::Util qw(first sum0);
43 use List::UtilsBy qw(sort_by uniq_by);
44 use List::MoreUtils qw(any none pairwise first_index);
45 use English qw(-no_match_vars);
50 use Rose::Object::MakeMethods::Generic
52 scalar => [ qw(item_ids_to_delete is_custom_shipto_to_delete) ],
53 'scalar --get_set_init' => [ qw(order valid_types type cv p all_price_factors search_cvpartnumber show_update_button part_picker_classification_ids type_data) ],
58 __PACKAGE__->run_before('check_auth',
59 except => [ qw(update_stock_information) ]);
61 __PACKAGE__->run_before('check_auth_for_edit',
62 except => [ qw(update_stock_information edit show_customer_vendor_details_dialog price_popup stock_in_out_dialog load_second_rows) ]);
64 __PACKAGE__->run_before('get_unalterable_data',
65 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
76 $self->order->transdate(DateTime->now_local());
77 $self->type_data->set_reqdate_by_type;
82 'delivery_order/form',
83 title => $self->get_title_for('add'),
84 %{$self->{template_args}}
88 sub action_add_from_order {
90 # this interfers with init_order
91 $self->{converted_from_oe_id} = delete $::form->{id};
93 $self->type_data->validate($::form->{type});
95 my $order = SL::DB::Order->new(id => $self->{converted_from_oe_id})->load;
97 $self->order(SL::DB::DeliveryOrder->new_from($order, type => $::form->{type}));
102 # edit an existing order
110 # this is to edit an order from an unsaved order object
112 # set item ids to new fake id, to identify them as new items
113 foreach my $item (@{$self->order->items_sorted}) {
114 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
116 # trigger rendering values for second row as hidden, because they
117 # are loaded only on demand. So we need to keep the values from
119 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
124 'delivery_order/form',
125 title => $self->get_title_for('edit'),
126 %{$self->{template_args}}
130 # edit a collective order (consisting of one or more existing orders)
131 sub action_edit_collective {
135 my @multi_ids = map {
136 $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1}
137 } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form;
139 # fall back to add if no ids are given
140 if (scalar @multi_ids == 0) {
145 # fall back to save as new if only one id is given
146 if (scalar @multi_ids == 1) {
147 $self->order(SL::DB::DeliveryOrder->new(id => $multi_ids[0])->load);
148 $self->action_save_as_new();
152 # make new order from given orders
153 my @multi_orders = map { SL::DB::DeliveryOrder->new(id => $_)->load } @multi_ids;
154 $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders;
155 $self->order(SL::DB::DeliveryOrder->new_from_multi(\@multi_orders, sort_sources_by => 'transdate'));
157 $self->action_edit();
164 my $errors = $self->delete();
166 if (scalar @{ $errors }) {
167 $self->js->flash('error', $_) foreach @{ $errors };
168 return $self->js->render();
171 flash_later('info', $self->type_data->text("delete"));
173 my @redirect_params = (
178 $self->redirect_to(@redirect_params);
185 my $errors = $self->save();
187 if (scalar @{ $errors }) {
188 $self->js->flash('error', $_) foreach @{ $errors };
189 return $self->js->render();
192 flash_later('info', $self->type_data->text("saved"));
194 my @redirect_params = (
197 id => $self->order->id,
200 $self->redirect_to(@redirect_params);
203 # save the order as new document an open it for edit
204 sub action_save_as_new {
207 my $order = $self->order;
210 $self->js->flash('error', t8('This object has not been saved yet.'));
211 return $self->js->render();
214 # load order from db to check if values changed
215 my $saved_order = SL::DB::DeliveryOrder->new(id => $order->id)->load;
218 # Lets assign a new number if the user hasn't changed the previous one.
219 # If it has been changed manually then use it as-is.
220 $new_attrs{number} = (trim($order->number) eq $saved_order->number)
222 : trim($order->number);
224 # Clear transdate unless changed
225 $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
226 ? DateTime->today_local
229 # Set new reqdate unless changed if it is enabled in client config
230 $new_attrs{reqdate} = $self->type_data->get_reqdate_by_type($order->reqdate, $saved_order->reqdate);
233 $new_attrs{employee} = SL::DB::Manager::Employee->current;
235 # Create new record from current one
236 $self->order(SL::DB::DeliveryOrder->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 my @warnings = store_pdf_to_webdav_and_filemanagement($self->order, $pdf, $pdf_filename);
320 if (scalar @warnings) {
321 $self->js->flash('warning', $_) for @warnings;
324 $self->save_history('PRINTED');
327 ->run('kivi.ActionBar.setEnabled', '#save_and_email_action')
330 sub action_preview_pdf {
333 my $errors = $self->save();
334 if (scalar @{ $errors }) {
335 $self->js->flash('error', $_) foreach @{ $errors };
336 return $self->js->render();
339 $self->js_reset_order_and_item_ids_after_save;
342 my $media = 'screen';
343 my $formname = $self->type;
346 # create a form for generate_attachment_filename
347 my $form = Form->new;
348 $form->{$self->nr_key()} = $self->order->number;
349 $form->{type} = $self->type;
350 $form->{format} = $format;
351 $form->{formname} = $formname;
352 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
353 my $pdf_filename = $form->generate_attachment_filename();
356 my @errors = generate_pdf($self->order, \$pdf, { format => $format,
357 formname => $formname,
358 language => $self->order->language,
360 if (scalar @errors) {
361 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
363 $self->save_history('PREVIEWED');
364 $self->js->flash('info', t8('The PDF has been previewed'));
368 type => SL::MIME->mime_type_from_ext($pdf_filename),
369 name => $pdf_filename,
374 # open the email dialog
375 sub action_save_and_show_email_dialog {
378 my $errors = $self->save();
380 if (scalar @{ $errors }) {
381 $self->js->flash('error', $_) foreach @{ $errors };
382 return $self->js->render();
385 my $cv_method = $self->cv;
387 if (!$self->order->$cv_method) {
388 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'))
393 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
394 $email_form->{to} ||= $self->order->$cv_method->email;
395 $email_form->{cc} = $self->order->$cv_method->cc;
396 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
397 # Todo: get addresses from shipto, if any
399 my $form = Form->new;
400 $form->{$self->nr_key()} = $self->order->number;
401 $form->{cusordnumber} = $self->order->cusordnumber;
402 $form->{formname} = $self->type;
403 $form->{type} = $self->type;
404 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
405 $form->{language_id} = $self->order->language->id if $self->order->language;
406 $form->{format} = 'pdf';
407 $form->{cp_id} = $self->order->contact->cp_id if $self->order->contact;
409 $email_form->{subject} = $form->generate_email_subject();
410 $email_form->{attachment_filename} = $form->generate_attachment_filename();
411 $email_form->{message} = $form->generate_email_body();
412 $email_form->{js_send_function} = 'kivi.DeliveryOrder.send_email()';
414 my %files = $self->get_files_for_email_dialog();
415 $self->{all_employees} = SL::DB::Manager::Employee->get_all(query => [ deleted => 0 ]);
416 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
417 email_form => $email_form,
418 show_bcc => $::auth->assert('email_bcc', 'may fail'),
420 is_customer => $self->type_data->is_customer,
421 ALL_EMPLOYEES => $self->{all_employees},
425 ->run('kivi.DeliveryOrder.show_email_dialog', $dialog_html)
432 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
433 sub action_send_email {
436 my $errors = $self->save();
438 if (scalar @{ $errors }) {
439 $self->js->run('kivi.DeliveryOrder.close_email_dialog');
440 $self->js->flash('error', $_) foreach @{ $errors };
441 return $self->js->render();
444 $self->js_reset_order_and_item_ids_after_save;
446 my $email_form = delete $::form->{email_form};
447 my %field_names = (to => 'email');
449 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
451 # for Form::cleanup which may be called in Form::send_email
452 $::form->{cwd} = getcwd();
453 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
455 $::form->{$_} = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
456 $::form->{media} = 'email';
458 if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) {
460 my @errors = generate_pdf($self->order, \$pdf, {media => $::form->{media},
461 format => $::form->{print_options}->{format},
462 formname => $::form->{print_options}->{formname},
463 language => $self->order->language,
464 printer_id => $::form->{print_options}->{printer_id},
465 groupitems => $::form->{print_options}->{groupitems}});
466 if (scalar @errors) {
467 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
470 my @warnings = store_pdf_to_webdav_and_filemanagement($self->order, $pdf, $::form->{attachment_filename});
471 if (scalar @warnings) {
472 flash_later('warning', $_) for @warnings;
475 my $sfile = SL::SessionFile::Random->new(mode => "w");
476 $sfile->fh->print($pdf);
479 $::form->{tmpfile} = $sfile->file_name;
480 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
483 $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a linked record to the mail
484 $::form->send_email(\%::myconfig, 'pdf');
486 # internal notes unless no email journal
487 unless ($::instance_conf->get_email_journal) {
489 my $intnotes = $self->order->intnotes;
490 $intnotes .= "\n\n" if $self->order->intnotes;
491 $intnotes .= t8('[email]') . "\n";
492 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
493 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
494 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
495 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
496 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
497 $intnotes .= t8('Message') . ": " . $::form->{message};
499 $self->order->update_attributes(intnotes => $intnotes);
502 $self->save_history('MAILED');
504 flash_later('info', t8('The email has been sent.'));
506 my @redirect_params = (
509 id => $self->order->id,
512 $self->redirect_to(@redirect_params);
515 # save the order and redirect to the frontend subroutine for a new
517 sub action_save_and_delivery_order {
520 $self->save_and_redirect_to(
521 controller => 'oe.pl',
522 action => 'oe_delivery_order_from_order',
526 # save the order and redirect to the frontend subroutine for a new
528 sub action_save_and_invoice {
531 $self->save_and_redirect_to(
532 controller => 'oe.pl',
533 action => 'oe_invoice_from_order',
537 # workflow from sales order to sales quotation
538 sub action_sales_quotation {
539 $_[0]->workflow_sales_or_request_for_quotation();
542 # workflow from sales order to sales quotation
543 sub action_request_for_quotation {
544 $_[0]->workflow_sales_or_request_for_quotation();
547 # workflow from sales quotation to sales order
548 sub action_sales_order {
549 $_[0]->workflow_sales_or_purchase_order();
552 # workflow from rfq to purchase order
553 sub action_purchase_order {
554 $_[0]->workflow_sales_or_purchase_order();
557 # workflow from purchase order to ap transaction
558 sub action_save_and_ap_transaction {
561 $self->save_and_redirect_to(
562 controller => 'ap.pl',
563 action => 'add_from_purchase_order',
567 # set form elements in respect to a changed customer or vendor
569 # This action is called on an change of the customer/vendor picker.
570 sub action_customer_vendor_changed {
573 setup_order_from_cv($self->order);
575 my $cv_method = $self->cv;
577 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
578 $self->js->show('#cp_row');
580 $self->js->hide('#cp_row');
583 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
584 $self->js->show('#shipto_selection');
586 $self->js->hide('#shipto_selection');
589 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
592 ->replaceWith('#order_cp_id', $self->build_contact_select)
593 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
594 ->replaceWith('#shipto_inputs ', $self->build_shipto_inputs)
595 ->replaceWith('#business_info_row', $self->build_business_info_row)
596 ->val( '#order_taxzone_id', $self->order->taxzone_id)
597 ->val( '#order_taxincluded', $self->order->taxincluded)
598 ->val( '#order_currency_id', $self->order->currency_id)
599 ->val( '#order_payment_id', $self->order->payment_id)
600 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
601 ->val( '#order_intnotes', $self->order->intnotes)
602 ->val( '#order_language_id', $self->order->$cv_method->language_id)
603 ->focus( '#order_' . $self->cv . '_id')
604 ->run('kivi.DeliveryOrder.update_exchangerate');
606 $self->js_redisplay_cvpartnumbers;
610 # open the dialog for customer/vendor details
611 sub action_show_customer_vendor_details_dialog {
614 my $is_customer = 'customer' eq $::form->{vc};
617 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
619 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
622 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
623 $details{discount_as_percent} = $cv->discount_as_percent;
624 $details{creditlimt} = $cv->creditlimit_as_number;
625 $details{business} = $cv->business->description if $cv->business;
626 $details{language} = $cv->language_obj->description if $cv->language_obj;
627 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
628 $details{payment_terms} = $cv->payment->description if $cv->payment;
629 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
631 foreach my $entry (@{ $cv->shipto }) {
632 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
634 foreach my $entry (@{ $cv->contacts }) {
635 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
638 $_[0]->render('common/show_vc_details', { layout => 0 },
639 is_customer => $is_customer,
644 # called if a unit in an existing item row is changed
645 sub action_unit_changed {
648 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
649 my $item = $self->order->items_sorted->[$idx];
651 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
652 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
655 ->run('kivi.DeliveryOrder.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
656 $self->js_redisplay_line_values;
660 # add an item row for a new item entered in the input row
661 sub action_add_item {
664 delete $::form->{add_item}->{create_part_type};
666 my $form_attr = $::form->{add_item};
668 return unless $form_attr->{parts_id};
670 my $item = new_item($self->order, $form_attr);
672 $self->order->add_items($item);
674 $self->get_item_cvpartnumber($item);
676 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
677 my $row_as_html = $self->p->render('delivery_order/tabs/_row',
681 in_out => $self->type_data->transfer,
684 if ($::form->{insert_before_item_id}) {
686 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
689 ->append('#row_table_id', $row_as_html);
692 if ( $item->part->is_assortment ) {
693 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
694 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
695 my $attr = { parts_id => $assortment_item->parts_id,
696 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
697 unit => $assortment_item->unit,
698 description => $assortment_item->part->description,
700 my $item = new_item($self->order, $attr);
702 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
703 $item->discount(1) unless $assortment_item->charge;
705 $self->order->add_items( $item );
706 $self->get_item_cvpartnumber($item);
707 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
708 my $row_as_html = $self->p->render('delivery_order/tabs/_row',
713 if ($::form->{insert_before_item_id}) {
715 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
718 ->append('#row_table_id', $row_as_html);
724 ->val('.add_item_input', '')
725 ->run('kivi.DeliveryOrder.init_row_handlers')
726 ->run('kivi.DeliveryOrder.renumber_positions')
727 ->focus('#add_item_parts_id_name');
729 $self->js->run('kivi.DeliveryOrder.row_table_scroll_down') if !$::form->{insert_before_item_id};
734 # add item rows for multiple items at once
735 sub action_add_multi_items {
738 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
739 return $self->js->render() unless scalar @form_attr;
742 foreach my $attr (@form_attr) {
743 my $item = new_item($self->order, $attr);
745 if ( $item->part->is_assortment ) {
746 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
747 my $attr = { parts_id => $assortment_item->parts_id,
748 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
749 unit => $assortment_item->unit,
750 description => $assortment_item->part->description,
752 my $item = new_item($self->order, $attr);
754 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
755 $item->discount(1) unless $assortment_item->charge;
760 $self->order->add_items(@items);
762 foreach my $item (@items) {
763 $self->get_item_cvpartnumber($item);
764 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
765 my $row_as_html = $self->p->render('delivery_order/tabs/_row',
769 in_out => $self->type_data->transfer,
772 if ($::form->{insert_before_item_id}) {
774 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
777 ->append('#row_table_id', $row_as_html);
782 ->run('kivi.Part.close_picker_dialogs')
783 ->run('kivi.DeliveryOrder.init_row_handlers')
784 ->run('kivi.DeliveryOrder.renumber_positions')
785 ->focus('#add_item_parts_id_name');
787 $self->js->run('kivi.DeliveryOrder.row_table_scroll_down') if !$::form->{insert_before_item_id};
792 sub action_update_exchangerate {
796 is_standard => $self->order->currency_id == $::instance_conf->get_currency_id,
797 currency_name => $self->order->currency->name,
800 $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
803 # redisplay item rows if they are sorted by an attribute
804 sub action_reorder_items {
808 partnumber => sub { $_[0]->part->partnumber },
809 description => sub { $_[0]->description },
810 qty => sub { $_[0]->qty },
811 sellprice => sub { $_[0]->sellprice },
812 discount => sub { $_[0]->discount },
813 cvpartnumber => sub { $_[0]->{cvpartnumber} },
816 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
818 my $method = $sort_keys{$::form->{order_by}};
819 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
820 if ($::form->{sort_dir}) {
821 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
822 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
824 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
827 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
828 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
830 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
834 ->run('kivi.DeliveryOrder.redisplay_items', \@to_sort)
838 # show the popup to choose a price/discount source
839 sub action_price_popup {
842 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
843 my $item = $self->order->items_sorted->[$idx];
845 $self->render_price_dialog($item);
848 # save the order in a session variable and redirect to the part controller
849 sub action_create_part {
852 my $previousform = $::auth->save_form_in_session(non_scalars => 1);
854 my $callback = $self->url_for(
855 action => 'return_from_create_part',
856 type => $self->type, # type is needed for check_auth on return
857 previousform => $previousform,
860 flash_later('info', t8('You are adding a new part while you are editing another document. You will be redirected to your document when saving the new part or aborting this form.'));
862 my @redirect_params = (
863 controller => 'Part',
865 part_type => $::form->{add_item}->{create_part_type},
866 callback => $callback,
870 $self->redirect_to(@redirect_params);
873 sub action_return_from_create_part {
876 $self->{created_part} = SL::DB::Part->new(id => delete $::form->{new_parts_id})->load if $::form->{new_parts_id};
878 $::auth->restore_form_from_session(delete $::form->{previousform});
880 # set item ids to new fake id, to identify them as new items
881 foreach my $item (@{$self->order->items_sorted}) {
882 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
885 $self->get_unalterable_data();
888 # trigger rendering values for second row/longdescription as hidden,
889 # because they are loaded only on demand. So we need to keep the values
891 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
892 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
895 'delivery_order/form',
896 title => $self->get_title_for('edit'),
897 %{$self->{template_args}}
902 sub action_stock_in_out_dialog {
905 my $part = SL::DB::Part->load_cached($::form->{parts_id}) or die "need parts_id";
906 my $unit = SL::DB::Unit->load_cached($::form->{unit}) or die "need unit";
907 my $stock = $::form->{stock};
908 my $row = $::form->{row};
909 my $item_id = $::form->{item_id};
910 my $qty = _parse_number($::form->{qty_as_number});
912 my $inout = $self->type_data->transfer;
914 my @contents = DO->get_item_availability(parts_id => $part->id);
915 my $stock_info = DO->unpack_stock_information(packed => $stock);
917 $self->merge_stock_data($stock_info, \@contents, $part, $unit);
919 $self->render("delivery_order/stock_dialog", { layout => 0 },
920 WHCONTENTS => $self->order->delivered ? $stock_info : \@contents,
923 do_unit => $unit->unit,
924 delivered => $self->order->delivered,
930 sub action_update_stock_information {
933 my $stock_info = $::form->{stock_info};
934 my $unit = $::form->{unit};
935 my $yaml = SL::YAML::Dump($stock_info);
936 my $stock_qty = $self->calculate_stock_in_out_from_stock_info($unit, $stock_info);
940 stock_qty => $stock_qty,
942 $self->render(\ SL::JSON::to_json($response), { layout => 0, type => 'json', process => 0 });
945 sub merge_stock_data {
946 my ($self, $stock_info, $contents, $part, $unit) = @_;
947 # TODO rewrite to mapping
949 if (!$self->order->delivered) {
950 for my $row (@$contents) {
951 # row here is in parts units. stock is in item units
952 $row->{available_qty} = _format_number($part->unit_obj->convert_to($row->{qty}, $unit));
954 for my $sinfo (@{ $stock_info }) {
955 next if $row->{bin_id} != $sinfo->{bin_id} ||
956 $row->{warehouse_id} != $sinfo->{warehouse_id} ||
957 $row->{chargenumber} ne $sinfo->{chargenumber} ||
958 $row->{bestbefore} ne $sinfo->{bestbefore};
960 $row->{"stock_$_"} = $sinfo->{$_}
961 for qw(qty unit error delivery_order_items_stock_id);
966 for my $sinfo (@{ $stock_info }) {
967 my $bin = SL::DB::Bin->load_cached($sinfo->{bin_id});
968 $sinfo->{warehousedescription} = $bin->warehouse->description;
969 $sinfo->{bindescription} = $bin->description;
970 map { $sinfo->{"stock_$_"} = $sinfo->{$_} } qw(qty unit);
975 # load the second row for one or more items
977 # This action gets the html code for all items second rows by rendering a template for
978 # the second row and sets the html code via client js.
979 sub action_load_second_rows {
982 foreach my $item_id (@{ $::form->{item_ids} }) {
983 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
984 my $item = $self->order->items_sorted->[$idx];
986 $self->js_load_second_row($item, $item_id, 0);
989 $self->js->run('kivi.DeliveryOrder.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
994 # update description, notes and sellprice from master data
995 sub action_update_row_from_master_data {
998 foreach my $item_id (@{ $::form->{item_ids} }) {
999 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1000 my $item = $self->order->items_sorted->[$idx];
1001 my $texts = get_part_texts($item->part, $self->order->language_id);
1003 $item->description($texts->{description});
1004 $item->longdescription($texts->{longdescription});
1006 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1009 if ($item->part->is_assortment) {
1010 # add assortment items with price 0, as the components carry the price
1011 $price_src = $price_source->price_from_source("");
1012 $price_src->price(0);
1014 $price_src = $price_source->best_price
1015 ? $price_source->best_price
1016 : $price_source->price_from_source("");
1017 $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate;
1018 $price_src->price(0) if !$price_source->best_price;
1022 $item->sellprice($price_src->price);
1023 $item->active_price_source($price_src);
1026 ->run('kivi.DeliveryOrder.update_sellprice', $item_id, $item->sellprice_as_number)
1027 ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
1028 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
1029 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
1031 if ($self->search_cvpartnumber) {
1032 $self->get_item_cvpartnumber($item);
1033 $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
1037 $self->js_redisplay_line_values;
1039 $self->js->render();
1042 sub action_transfer_stock {
1045 if ($self->order->delivered) {
1046 return $self->js->flash("error", t8('The parts for this order have already been transferred'))->render;
1049 my $inout = $self->type_data->properties('transfer');
1051 my $errors = $self->save;
1054 $self->js->flash('error', $_) for @$errors;
1055 return $self->js->render;
1058 my $order = $self->order;
1060 # TODO move to type data
1061 my $trans_type = $inout eq 'in'
1062 ? SL::DB::Manager::TransferType->find_by(direction => "id", description => "stock")
1063 : SL::DB::Manager::TransferType->find_by(direction => "out", description => "shipped");
1065 my @transfer_requests;
1067 for my $item (@{ $order->items_sorted }) {
1068 for my $stock (@{ $item->delivery_order_stock_entries }) {
1069 my $transfer = SL::DB::Inventory->new_from($stock);
1070 $transfer->trans_type($trans_type);
1071 $transfer->qty($transfer->qty * -1) if $inout eq 'out';
1073 push @transfer_requests, $transfer if defined $transfer->qty && $transfer->qty != 0;
1077 if (!@transfer_requests) {
1078 return $self->js->flash("error", t8("No stock to transfer"))->render;
1081 SL::DB->client->with_transaction(sub {
1082 $_->save for @transfer_requests;
1083 $self->order->update_attributes(delivered => 1);
1087 ->flash("info", t8("Stock transfered"))
1088 ->run('kivi.ActionBar.setDisabled', '#transfer_out_action', t8('The parts for this order have already been transferred'))
1089 ->run('kivi.ActionBar.setDisabled', '#transfer_in_action', t8('The parts for this order have already been transferred'))
1090 ->run('kivi.ActionBar.setDisabled', '#delete_action', t8('The parts for this order have already been transferred'))
1091 ->replaceWith('#data-status-line', delivery_order_status_line($self->order))
1096 sub js_load_second_row {
1097 my ($self, $item, $item_id, $do_parse) = @_;
1100 # Parse values from form (they are formated while rendering (template)).
1101 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1102 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1103 foreach my $var (@{ $item->cvars_by_config }) {
1104 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1106 $item->parse_custom_variable_values;
1109 my $row_as_html = $self->p->render('delivery_order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1112 ->html('#second_row_' . $item_id, $row_as_html)
1113 ->data('#second_row_' . $item_id, 'loaded', 1);
1116 sub js_redisplay_line_values {
1119 my $is_sales = $self->order->is_sales;
1121 # sales orders with margins
1126 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1127 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1128 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1129 ]} @{ $self->order->items_sorted };
1133 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1134 ]} @{ $self->order->items_sorted };
1138 ->run('kivi.DeliveryOrder.redisplay_line_values', $is_sales, \@data);
1141 sub js_redisplay_cvpartnumbers {
1144 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1146 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1149 ->run('kivi.DeliveryOrder.redisplay_cvpartnumbers', \@data);
1152 sub js_reset_order_and_item_ids_after_save {
1156 ->val('#id', $self->order->id)
1157 ->val('#converted_from_oe_id', '')
1158 ->val('#order_' . $self->nr_key(), $self->order->number);
1161 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1162 next if !$self->order->items_sorted->[$idx]->id;
1163 next if $form_item_id !~ m{^new};
1165 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1166 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1167 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1171 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1181 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1182 die "Not a valid type for delivery order";
1185 $self->type($::form->{type});
1191 return $self->type_data->customervendor;
1194 sub init_search_cvpartnumber {
1197 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1198 my $search_cvpartnumber;
1199 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1200 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1202 return $search_cvpartnumber;
1205 sub init_show_update_button {
1208 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1219 sub init_all_price_factors {
1220 SL::DB::Manager::PriceFactor->get_all;
1223 sub init_part_picker_classification_ids {
1226 return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => $self->type_data->part_classification_query) } ];
1232 $::auth->assert($self->type_data->access('view') || 'DOES_NOT_EXIST');
1235 sub check_auth_for_edit {
1238 $::auth->assert($self->type_data->access('edit') || 'DOES_NOT_EXIST');
1241 # build the selection box for contacts
1243 # Needed, if customer/vendor changed.
1244 sub build_contact_select {
1247 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1248 value_key => 'cp_id',
1249 title_key => 'full_name_dep',
1250 default => $self->order->cp_id,
1252 style => 'width: 300px',
1256 # build the selection box for shiptos
1258 # Needed, if customer/vendor changed.
1259 sub build_shipto_select {
1262 select_tag('order.shipto_id',
1263 [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
1264 value_key => 'shipto_id',
1265 title_key => 'displayable_id',
1266 default => $self->order->shipto_id,
1268 style => 'width: 300px',
1272 # build the inputs for the cusom shipto dialog
1274 # Needed, if customer/vendor changed.
1275 sub build_shipto_inputs {
1278 my $content = $self->p->render('common/_ship_to_dialog',
1279 vc_obj => $self->order->customervendor,
1280 cs_obj => $self->order->custom_shipto,
1281 cvars => $self->order->custom_shipto->cvars_by_config,
1282 id_selector => '#order_shipto_id');
1284 div_tag($content, id => 'shipto_inputs');
1287 # render the info line for business
1289 # Needed, if customer/vendor changed.
1290 sub build_business_info_row
1292 $_[0]->p->render('delivery_order/tabs/_business_info_row', SELF => $_[0]);
1299 return if !$::form->{id};
1301 $self->order(SL::DB::DeliveryOrder->new(id => $::form->{id})->load);
1303 # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
1304 # You need a custom shipto object to call cvars_by_config to get the cvars.
1305 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
1307 $self->prepare_stock_info($_) for $self->order->items;
1309 return $self->order;
1312 # load or create a new order object
1314 # And assign changes from the form to this object.
1315 # If the order is loaded from db, check if items are deleted in the form,
1316 # remove them form the object and collect them for removing from db on saving.
1317 # Then create/update items from form (via make_item) and add them.
1321 # add_items adds items to an order with no items for saving, but they cannot
1322 # be retrieved via items until the order is saved. Adding empty items to new
1323 # order here solves this problem.
1325 $order = SL::DB::DeliveryOrder->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1326 $order ||= SL::DB::DeliveryOrder->new(orderitems => [], currency_id => $::instance_conf->get_currency_id(), order_type => $self->type_data->validate($::form->{type}));
1328 my $cv_id_method = $self->cv . '_id';
1329 if (!$::form->{id} && $::form->{$cv_id_method}) {
1330 $order->$cv_id_method($::form->{$cv_id_method});
1331 setup_order_from_cv($order);
1334 my $form_orderitems = delete $::form->{order}->{orderitems};
1336 $order->assign_attributes(%{$::form->{order}});
1338 $self->setup_custom_shipto_from_form($order, $::form);
1340 # remove deleted items
1341 $self->item_ids_to_delete([]);
1342 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1343 my $item = $order->orderitems->[$idx];
1344 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1345 splice @{$order->orderitems}, $idx, 1;
1346 push @{$self->item_ids_to_delete}, $item->id;
1352 foreach my $form_attr (@{$form_orderitems}) {
1353 my $item = make_item($order, $form_attr);
1354 $item->position($pos);
1359 $self->prepare_stock_info($_) for $order->items, @items;
1361 $order->add_items(grep {!$_->id} @items);
1366 # create or update items from form
1368 # Make item objects from form values. For items already existing read from db.
1369 # Create a new item else. And assign attributes.
1371 my ($record, $attr) = @_;
1374 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1376 my $is_new = !$item;
1378 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1379 # they cannot be retrieved via custom_variables until the order/orderitem is
1380 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1381 $item ||= SL::DB::DeliveryOrderItem->new(custom_variables => []);
1384 if (my $stock_info = delete $attr->{stock_info}) {
1385 my %existing = map { $_->id => $_ } $item->delivery_order_stock_entries;
1388 for my $line (@{ DO->unpack_stock_information(packed => $stock_info) }) {
1389 # lookup existing or make new
1390 my $obj = delete $existing{$line->{delivery_order_items_stock_id}}
1391 // SL::DB::DeliveryOrderItemsStock->new;
1394 $obj->$_($line->{$_}) for qw(bin_id warehouse_id chargenumber qty unit);
1395 $obj->bestbefore_as_date($line->{bestfbefore})
1396 if $line->{bestbefore} && $::instance_conf->get_show_bestbefore;
1397 push @save, $obj if $obj->qty;
1400 $item->delivery_order_stock_entries(@save);
1403 $item->assign_attributes(%$attr);
1406 my $texts = get_part_texts($item->part, $record->language_id);
1407 $item->longdescription($texts->{longdescription}) if !defined $attr->{longdescription};
1408 $item->project_id($record->globalproject_id) if !defined $attr->{project_id};
1409 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number};
1417 # This is used to add one item
1419 my ($record, $attr) = @_;
1421 my $item = SL::DB::DeliveryOrderItem->new;
1423 # Remove attributes where the user left or set the inputs empty.
1424 # So these attributes will be undefined and we can distinguish them
1425 # from zero later on.
1426 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1427 delete $attr->{$_} if $attr->{$_} eq '';
1430 $item->assign_attributes(%$attr);
1432 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1433 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1435 $item->unit($part->unit) if !$item->unit;
1438 if ( $part->is_assortment ) {
1439 # add assortment items with price 0, as the components carry the price
1440 $price_src = $price_source->price_from_source("");
1441 $price_src->price(0);
1442 } elsif (defined $item->sellprice) {
1443 $price_src = $price_source->price_from_source("");
1444 $price_src->price($item->sellprice);
1446 $price_src = $price_source->best_price
1447 ? $price_source->best_price
1448 : $price_source->price_from_source("");
1449 $price_src->price(0) if !$price_source->best_price;
1453 if (defined $item->discount) {
1454 $discount_src = $price_source->discount_from_source("");
1455 $discount_src->discount($item->discount);
1457 $discount_src = $price_source->best_discount
1458 ? $price_source->best_discount
1459 : $price_source->discount_from_source("");
1460 $discount_src->discount(0) if !$price_source->best_discount;
1464 $new_attr{part} = $part;
1465 $new_attr{description} = $part->description if ! $item->description;
1466 $new_attr{qty} = 1.0 if ! $item->qty;
1467 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1468 $new_attr{sellprice} = $price_src->price;
1469 $new_attr{discount} = $discount_src->discount;
1470 $new_attr{active_price_source} = $price_src;
1471 $new_attr{active_discount_source} = $discount_src;
1472 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1473 $new_attr{project_id} = $record->globalproject_id;
1474 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1476 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1477 # they cannot be retrieved via custom_variables until the order/orderitem is
1478 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1479 $new_attr{custom_variables} = [];
1481 my $texts = get_part_texts($part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription});
1483 $item->assign_attributes(%new_attr, %{ $texts });
1488 sub prepare_stock_info {
1489 my ($self, $item) = @_;
1491 $item->{stock_info} = SL::YAML::Dump([
1493 delivery_order_items_stock_id => $_->id,
1495 warehouse_id => $_->warehouse_id,
1496 bin_id => $_->bin_id,
1497 chargenumber => $_->chargenumber,
1499 }, $item->delivery_order_stock_entries
1503 sub setup_order_from_cv {
1506 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id));
1508 $order->intnotes($order->customervendor->notes);
1510 if ($order->is_sales) {
1511 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1512 $order->taxincluded(defined($order->customer->taxincluded_checked)
1513 ? $order->customer->taxincluded_checked
1514 : $::myconfig{taxincluded_checked});
1519 # setup custom shipto from form
1521 # The dialog returns form variables starting with 'shipto' and cvars starting
1522 # with 'shiptocvar_'.
1523 # Mark it to be deleted if a shipto from master data is selected
1524 # (i.e. order has a shipto).
1525 # Else, update or create a new custom shipto. If the fields are empty, it
1526 # will not be saved on save.
1527 sub setup_custom_shipto_from_form {
1528 my ($self, $order, $form) = @_;
1530 if ($order->shipto) {
1531 $self->is_custom_shipto_to_delete(1);
1533 my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1535 my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
1536 my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
1538 $custom_shipto->assign_attributes(%$shipto_attrs);
1539 $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
1543 # get data for saving, printing, ..., that is not changed in the form
1545 # Only cvars for now.
1546 sub get_unalterable_data {
1549 foreach my $item (@{ $self->order->items }) {
1550 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1551 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1552 foreach my $var (@{ $item->cvars_by_config }) {
1553 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1555 $item->parse_custom_variable_values;
1561 # And remove related files in the spool directory
1566 my $db = $self->order->db;
1568 $db->with_transaction(
1570 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1571 $self->order->delete;
1572 my $spool = $::lx_office_conf{paths}->{spool};
1573 unlink map { "$spool/$_" } @spoolfiles if $spool;
1575 $self->save_history('DELETED');
1578 }) || push(@{$errors}, $db->error);
1585 # And delete items that are deleted in the form.
1590 my $db = $self->order->db;
1592 $db->with_transaction(sub {
1593 # delete custom shipto if it is to be deleted or if it is empty
1594 if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
1595 $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
1596 $self->order->custom_shipto(undef);
1599 SL::DB::DeliveryOrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1600 $self->order->save(cascade => 1);
1603 if ($::form->{converted_from_oe_id}) {
1604 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1605 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1606 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1607 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/ && $self->order->is_type(PURCHASE_DELIVERY_ORDER_TYPE);
1608 $src->link_to_record($self->order);
1610 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1612 foreach (@{ $self->order->items_sorted }) {
1613 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1615 SL::DB::RecordLink->new(from_table => 'orderitems',
1616 from_id => $from_id,
1617 to_table => 'orderitems',
1625 $self->save_history('SAVED');
1628 }) || push(@{$errors}, $db->error);
1633 sub workflow_sales_or_request_for_quotation {
1637 my $errors = $self->save();
1639 if (scalar @{ $errors }) {
1640 $self->js->flash('error', $_) for @{ $errors };
1641 return $self->js->render();
1644 my $destination_type = $self->type_data->workflow("to_quotation_type");
1646 $self->order(SL::DB::DeliveryOrder->new_from($self->order, destination_type => $destination_type));
1647 $self->{converted_from_oe_id} = delete $::form->{id};
1649 # set item ids to new fake id, to identify them as new items
1650 foreach my $item (@{$self->order->items_sorted}) {
1651 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1655 $::form->{type} = $destination_type;
1656 $self->type($self->init_type);
1657 $self->cv ($self->init_cv);
1660 $self->get_unalterable_data();
1661 $self->pre_render();
1663 # trigger rendering values for second row as hidden, because they
1664 # are loaded only on demand. So we need to keep the values from the
1666 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1669 'delivery_order/form',
1670 title => $self->get_title_for('edit'),
1671 %{$self->{template_args}}
1675 sub workflow_sales_or_purchase_order {
1679 my $errors = $self->save();
1681 if (scalar @{ $errors }) {
1682 $self->js->flash('error', $_) foreach @{ $errors };
1683 return $self->js->render();
1686 my $destination_type = $self->type_data->workflow("to_order_type");
1688 # check for direct delivery
1689 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
1691 if ($self->type_data->workflow("to_order_copy_shipto") && $::form->{use_shipto} && $self->order->shipto) {
1692 $custom_shipto = $self->order->shipto->clone('SL::DB::DeliveryOrder');
1695 $self->order(SL::DB::DeliveryOrder->new_from($self->order, destination_type => $destination_type));
1696 $self->{converted_from_oe_id} = delete $::form->{id};
1698 # set item ids to new fake id, to identify them as new items
1699 foreach my $item (@{$self->order->items_sorted}) {
1700 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1703 if ($self->type_data->workflow("to_order_copy_shipto")) {
1704 if ($::form->{use_shipto}) {
1705 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
1707 # remove any custom shipto if not wanted
1708 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1713 $::form->{type} = $destination_type;
1714 $self->type($self->init_type);
1715 $self->cv ($self->init_cv);
1718 $self->get_unalterable_data();
1719 $self->pre_render();
1721 # trigger rendering values for second row as hidden, because they
1722 # are loaded only on demand. So we need to keep the values from the
1724 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1727 'delivery_order/form',
1728 title => $self->get_title_for('edit'),
1729 %{$self->{template_args}}
1736 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1737 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
1738 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1739 $self->{all_languages} = SL::DB::Manager::Language->get_all_sorted( query => [ or => [ obsolete => 0, id => $self->order->language_id ] ] );
1740 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1743 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1746 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1748 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1749 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1750 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1751 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1753 my $print_form = Form->new('');
1754 $print_form->{type} = $self->type;
1755 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1756 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1757 form => $print_form,
1758 options => {dialog_name_prefix => 'print_options.',
1762 no_opendocument => 0,
1766 foreach my $item (@{$self->order->orderitems}) {
1767 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1768 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1769 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1772 if ($self->order->${\ $self->type_data->nr_key } && $::instance_conf->get_webdav) {
1773 my $webdav = SL::Webdav->new(
1774 type => $self->type,
1775 number => $self->order->number,
1777 my @all_objects = $webdav->get_all_objects;
1778 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1780 link => File::Spec->catfile($_->full_filedescriptor),
1784 $self->{template_args}{in_out} = $self->type_data->transfer;
1786 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1788 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.DeliveryOrder kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
1789 calculate_qty kivi.Validator follow_up show_history);
1790 $self->setup_edit_action_bar;
1793 sub setup_edit_action_bar {
1794 my ($self, %params) = @_;
1796 my $deletion_allowed = $self->type_data->show_menu("delete");
1797 my $may_edit_create = $::auth->assert($self->type_data->access('edit') || 'DOES_NOT_EXIST', 1);
1799 for my $bar ($::request->layout->get('actionbar')) {
1804 call => [ 'kivi.DeliveryOrder.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1805 $::instance_conf->get_order_warn_no_deliverydate,
1807 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1811 call => [ 'kivi.DeliveryOrder.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1812 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
1813 : $self->type eq 'supplier_delivery_order' ? t8('Need a workflow for Supplier Delivery Order')
1814 : !$self->order->id ? t8('This object has not been saved yet.')
1817 ], # end of combobox "Save"
1824 t8('Save and Quotation'),
1825 submit => [ '#order_form', { action => "DeliveryOrder/sales_quotation" } ],
1826 only_if => $self->type_data->show_menu("save_and_quotation"),
1827 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1831 submit => [ '#order_form', { action => "DeliveryOrder/request_for_quotation" } ],
1832 only_if => $self->type_data->show_menu("save_and_rfq"),
1833 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1836 t8('Save and Sales Order'),
1837 submit => [ '#order_form', { action => "DeliveryOrder/sales_order" } ],
1838 only_if => $self->type_data->show_menu("save_and_sales_order"),
1839 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1842 t8('Save and Purchase Order'),
1843 call => [ 'kivi.DeliveryOrder.purchase_order_check_for_direct_delivery' ],
1844 only_if => $self->type_data->show_menu("save_and_purchase_order"),
1845 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1848 t8('Save and Delivery Order'),
1849 call => [ 'kivi.DeliveryOrder.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1850 $::instance_conf->get_order_warn_no_deliverydate,
1852 only_if => $self->type_data->show_menu("save_and_delivery_order"),
1853 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1856 t8('Save and Invoice'),
1857 call => [ 'kivi.DeliveryOrder.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1858 only_if => $self->type_data->show_menu("save_and_invoice"),
1859 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1862 t8('Save and AP Transaction'),
1863 call => [ 'kivi.DeliveryOrder.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
1864 only_if => $self->type_data->show_menu("save_and_ap_transaction"),
1865 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1868 ], # end of combobox "Workflow"
1875 t8('Save and preview PDF'),
1876 call => [ 'kivi.DeliveryOrder.save', 'preview_pdf', $::instance_conf->get_order_warn_duplicate_parts,
1877 $::instance_conf->get_order_warn_no_deliverydate,
1879 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1882 t8('Save and print'),
1883 call => [ 'kivi.DeliveryOrder.show_print_options', $::instance_conf->get_order_warn_duplicate_parts,
1884 $::instance_conf->get_order_warn_no_deliverydate,
1886 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1889 t8('Save and E-mail'),
1890 id => 'save_and_email_action',
1891 call => [ 'kivi.DeliveryOrder.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts,
1892 $::instance_conf->get_order_warn_no_deliverydate,
1894 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
1895 : !$self->order->id ? t8('This object has not been saved yet.')
1899 t8('Download attachments of all parts'),
1900 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1901 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
1902 : !$self->order->id ? t8('This object has not been saved yet.')
1904 only_if => $::instance_conf->get_doc_storage,
1906 ], # end of combobox "Export"
1910 id => 'delete_action',
1911 call => [ 'kivi.DeliveryOrder.delete_order' ],
1912 confirm => $::locale->text('Do you really want to delete this object?'),
1913 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
1914 : !$self->order->id ? t8('This object has not been saved yet.')
1915 : $self->order->delivered ? t8('The parts for this order have already been transferred')
1917 only_if => $self->type_data->show_menu("delete"),
1923 id => 'transfer_out_action',
1924 call => [ 'kivi.DeliveryOrder.save', 'transfer_stock' ],
1925 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
1926 : !$self->order->id ? t8('This object has not been saved yet.')
1927 : $self->order->delivered ? t8('The parts for this order have already been transferred')
1929 only_if => $self->type_data->properties('transfer') eq 'out',
1930 confirm => t8('Do you really want to transfer the stock and set this order to delivered?'),
1934 id => 'transfer_in_action',
1935 call => [ 'kivi.DeliveryOrder.save', 'transfer_stock' ],
1936 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
1937 : !$self->order->id ? t8('This object has not been saved yet.')
1938 : $self->order->delivered ? t8('The parts for this order have already been transferred')
1940 only_if => $self->type_data->properties('transfer') eq 'in',
1941 confirm => t8('Do you really want to transfer the stock and set this order to delivered?'),
1951 call => [ 'kivi.DeliveryOrder.follow_up_window' ],
1952 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1953 only_if => $::auth->assert('productivity', 1),
1957 call => [ 'set_history_window', $self->order->id, 'id' ],
1958 disabled => !$self->order->id ? t8('This record has not been saved yet.') : undef,
1960 ], # end of combobox "more"
1966 my ($order, $pdf_ref, $params) = @_;
1970 my $print_form = Form->new('');
1971 $print_form->{type} = $order->type;
1972 $print_form->{formname} = $params->{formname} || $order->type;
1973 $print_form->{format} = $params->{format} || 'pdf';
1974 $print_form->{media} = $params->{media} || 'file';
1975 $print_form->{groupitems} = $params->{groupitems};
1976 $print_form->{printer_id} = $params->{printer_id};
1977 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1979 $order->language($params->{language});
1980 $order->flatten_to_form($print_form, format_amounts => 1);
1984 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
1985 $template_ext = 'odt';
1986 $template_type = 'OpenDocument';
1989 # search for the template
1990 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1991 name => $print_form->{formname},
1992 extension => $template_ext,
1993 email => $print_form->{media} eq 'email',
1994 language => $params->{language},
1995 printer_id => $print_form->{printer_id},
1998 if (!defined $template_file) {
1999 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);
2002 return @errors if scalar @errors;
2004 $print_form->throw_on_error(sub {
2006 $print_form->prepare_for_printing;
2008 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
2009 format => $print_form->{format},
2010 template_type => $template_type,
2011 template => $template_file,
2012 variables => $print_form,
2013 variable_content_types => {
2014 longdescription => 'html',
2015 partnotes => 'html',
2020 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
2026 sub get_files_for_email_dialog {
2029 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
2031 return %files if !$::instance_conf->get_doc_storage;
2033 if ($self->order->id) {
2034 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
2035 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
2036 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
2037 $files{project_files} = [ SL::File->get_all( object_id => $self->order->globalproject_id, object_type => 'project', file_type => 'attachment') ];
2041 uniq_by { $_->{id} }
2043 +{ id => $_->part->id,
2044 partnumber => $_->part->partnumber }
2045 } @{$self->order->items_sorted};
2047 foreach my $part (@parts) {
2048 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
2049 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
2052 foreach my $key (keys %files) {
2053 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
2060 my ($self, $action) = @_;
2062 return '' if none { lc($action)} qw(add edit);
2063 return $self->type_data->text($action);
2066 sub get_item_cvpartnumber {
2067 my ($self, $item) = @_;
2069 return if !$self->search_cvpartnumber;
2070 return if !$self->order->customervendor;
2072 if ($self->cv eq 'vendor') {
2073 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
2074 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
2075 } elsif ($self->cv eq 'customer') {
2076 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
2077 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
2081 sub get_part_texts {
2082 my ($part_or_id, $language_or_id, %defaults) = @_;
2084 my $part = ref($part_or_id) ? $part_or_id : SL::DB::Part->load_cached($part_or_id);
2085 my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id;
2087 description => $defaults{description} // $part->description,
2088 longdescription => $defaults{longdescription} // $part->notes,
2091 return $texts unless $language_id;
2093 my $translation = SL::DB::Manager::Translation->get_first(
2095 parts_id => $part->id,
2096 language_id => $language_id,
2099 $texts->{description} = $translation->translation if $translation && $translation->translation;
2100 $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription;
2106 return $_[0]->type_data->nr_key;
2109 sub save_and_redirect_to {
2110 my ($self, %params) = @_;
2112 my $errors = $self->save();
2114 if (scalar @{ $errors }) {
2115 $self->js->flash('error', $_) foreach @{ $errors };
2116 return $self->js->render();
2119 flash_later('info', $self->type_data->text("saved"));
2121 $self->redirect_to(%params, id => $self->order->id);
2125 my ($self, $addition) = @_;
2127 my $number_type = $self->nr_key;
2128 my $snumbers = $number_type . '_' . $self->order->$number_type;
2130 SL::DB::History->new(
2131 trans_id => $self->order->id,
2132 employee_id => SL::DB::Manager::Employee->current->id,
2133 what_done => $self->order->type,
2134 snumbers => $snumbers,
2135 addition => $addition,
2139 sub store_pdf_to_webdav_and_filemanagement {
2140 my($order, $content, $filename) = @_;
2144 # copy file to webdav folder
2145 if ($order->number && $::instance_conf->get_webdav_documents) {
2146 my $webdav = SL::Webdav->new(
2147 type => $order->type,
2148 number => $order->number,
2150 my $webdav_file = SL::Webdav::File->new(
2152 filename => $filename,
2155 $webdav_file->store(data => \$content);
2158 push @errors, t8('Storing PDF to webdav folder failed: #1', $@);
2161 if ($order->id && $::instance_conf->get_doc_storage) {
2163 SL::File->save(object_id => $order->id,
2164 object_type => $order->type,
2165 mime_type => 'application/pdf',
2166 source => 'created',
2167 file_type => 'document',
2168 file_name => $filename,
2169 file_contents => $content);
2172 push @errors, t8('Storing PDF in storage backend failed: #1', $@);
2179 sub calculate_stock_in_out_from_stock_info {
2180 my ($self, $unit, $stock_info) = @_;
2182 return "" if !$unit;
2184 my %units_by_name = map { $_->name => $_ } @{ SL::DB::Manager::Unit->get_all };
2186 my $sum = sum0 map {
2187 $units_by_name{$_->{unit}}->convert_to($_->{qty}, $units_by_name{$unit})
2190 my $content = _format_number($sum, 2) . ' ' . $unit;
2195 sub calculate_stock_in_out {
2196 my ($self, $item, $stock_info) = @_;
2198 return "" if !$item->part || !$item->part->unit || !$item->unit;
2200 my $sum = sum0 map {
2201 $_->unit_obj->convert_to($_->qty, $item->unit_obj)
2202 } $item->delivery_order_stock_entries;
2204 my $content = _format_number($sum, 2);
2209 sub init_type_data {
2210 SL::Controller::DeliveryOrder::TypeData->new($_[0]);
2213 sub init_valid_types {
2214 $_[0]->type_data->valid_types;
2225 SL::Controller::Order - controller for orders
2229 This is a new form to enter orders, completely rewritten with the use
2230 of controller and java script techniques.
2232 The aim is to provide the user a better experience and a faster workflow. Also
2233 the code should be more readable, more reliable and better to maintain.
2241 One input row, so that input happens every time at the same place.
2245 Use of pickers where possible.
2249 Possibility to enter more than one item at once.
2253 Item list in a scrollable area, so that the workflow buttons stay at
2258 Reordering item rows with drag and drop is possible. Sorting item rows is
2259 possible (by partnumber, description, qty, sellprice and discount for now).
2263 No C<update> is necessary. All entries and calculations are managed
2264 with ajax-calls and the page only reloads on C<save>.
2268 User can see changes immediately, because of the use of java script
2279 =item * C<SL/Controller/Order.pm>
2283 =item * C<template/webpages/delivery_order/form.html>
2287 =item * C<template/webpages/delivery_order/tabs/basic_data.html>
2289 Main tab for basic_data.
2291 This is the only tab here for now. "linked records" and "webdav" tabs are
2292 reused from generic code.
2296 =item * C<template/webpages/delivery_order/tabs/_business_info_row.html>
2298 For displaying information on business type
2300 =item * C<template/webpages/delivery_order/tabs/_item_input.html>
2302 The input line for items
2304 =item * C<template/webpages/delivery_order/tabs/_row.html>
2306 One row for already entered items
2308 =item * C<template/webpages/delivery_order/tabs/_tax_row.html>
2310 Displaying tax information
2314 =item * C<js/kivi.DeliveryOrder.js>
2316 java script functions
2326 =item * price sources: little symbols showing better price / better discount
2328 =item * select units in input row?
2330 =item * check for direct delivery (workflow sales order -> purchase order)
2332 =item * access rights
2334 =item * display weights
2338 =item * optional client/user behaviour
2340 (transactions has to be set - department has to be set -
2341 force project if enabled in client config - transport cost reminder)
2345 =head1 KNOWN BUGS AND CAVEATS
2351 Customer discount is not displayed as a valid discount in price source popup
2352 (this might be a bug in price sources)
2354 (I cannot reproduce this (Bernd))
2358 No indication that <shift>-up/down expands/collapses second row.
2362 Inline creation of parts is not currently supported
2366 Table header is not sticky in the scrolling area.
2370 Sorting does not include C<position>, neither does reordering.
2372 This behavior was implemented intentionally. But we can discuss, which behavior
2373 should be implemented.
2377 =head1 To discuss / Nice to have
2383 How to expand/collapse second row. Now it can be done clicking the icon or
2388 Possibility to select PriceSources in input row?
2392 This controller uses a (changed) copy of the template for the PriceSource
2393 dialog. Maybe there could be used one code source.
2397 Rounding-differences between this controller (PriceTaxCalculator) and the old
2398 form. This is not only a problem here, but also in all parts using the PTC.
2399 There exists a ticket and a patch. This patch should be testet.
2403 An indicator, if the actual inputs are saved (like in an
2404 editor or on text processing application).
2408 A warning when leaving the page without saveing unchanged inputs.
2415 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>