OrderController Typo
[kivitendo-erp.git] / SL / Controller / Order.pm
1 package SL::Controller::Order;
2
3 use strict;
4 use parent qw(SL::Controller::Base);
5
6 use SL::Helper::Flash qw(flash_later);
7 use SL::Presenter::Tag qw(select_tag hidden_tag);
8 use SL::Locale::String qw(t8);
9 use SL::SessionFile::Random;
10 use SL::PriceSource;
11 use SL::Webdav;
12 use SL::File;
13 use SL::Util qw(trim);
14 use SL::DB::Order;
15 use SL::DB::Default;
16 use SL::DB::Unit;
17 use SL::DB::Part;
18 use SL::DB::PartsGroup;
19 use SL::DB::Printer;
20 use SL::DB::Language;
21 use SL::DB::RecordLink;
22
23 use SL::Helper::CreatePDF qw(:all);
24 use SL::Helper::PrintOptions;
25 use SL::Helper::ShippedQty;
26
27 use SL::Controller::Helper::GetModels;
28
29 use List::Util qw(first);
30 use List::UtilsBy qw(sort_by uniq_by);
31 use List::MoreUtils qw(any none pairwise first_index);
32 use English qw(-no_match_vars);
33 use File::Spec;
34 use Cwd;
35
36 use Rose::Object::MakeMethods::Generic
37 (
38  scalar => [ qw(item_ids_to_delete) ],
39  'scalar --get_set_init' => [ qw(order valid_types type cv p multi_items_models all_price_factors) ],
40 );
41
42
43 # safety
44 __PACKAGE__->run_before('check_auth');
45
46 __PACKAGE__->run_before('recalc',
47                         only => [ qw(save save_as_new save_and_delivery_order save_and_invoice print send_email) ]);
48
49 __PACKAGE__->run_before('get_unalterable_data',
50                         only => [ qw(save save_as_new save_and_delivery_order save_and_invoice print send_email) ]);
51
52 #
53 # actions
54 #
55
56 # add a new order
57 sub action_add {
58   my ($self) = @_;
59
60   $self->order->transdate(DateTime->now_local());
61   my $extra_days = $self->type eq sales_quotation_type() ? $::instance_conf->get_reqdate_interval : 1;
62   $self->order->reqdate(DateTime->today_local->next_workday(extra_days => $extra_days)) if !$self->order->reqdate;
63
64   $self->pre_render();
65   $self->render(
66     'order/form',
67     title => $self->get_title_for('add'),
68     %{$self->{template_args}}
69   );
70 }
71
72 # edit an existing order
73 sub action_edit {
74   my ($self) = @_;
75
76   if ($::form->{id}) {
77     $self->load_order;
78
79   } else {
80     # this is to edit an order from an unsaved order object
81
82     # set item ids to new fake id, to identify them as new items
83     foreach my $item (@{$self->order->items_sorted}) {
84       $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
85     }
86     # trigger rendering values for second row/longdescription as hidden,
87     # because they are loaded only on demand. So we need to keep the values
88     # from the source.
89     $_->{render_second_row}      = 1 for @{ $self->order->items_sorted };
90     $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
91   }
92
93   $self->recalc();
94   $self->pre_render();
95   $self->render(
96     'order/form',
97     title => $self->get_title_for('edit'),
98     %{$self->{template_args}}
99   );
100 }
101
102 # delete the order
103 sub action_delete {
104   my ($self) = @_;
105
106   my $errors = $self->delete();
107
108   if (scalar @{ $errors }) {
109     $self->js->flash('error', $_) foreach @{ $errors };
110     return $self->js->render();
111   }
112
113   my $text = $self->type eq sales_order_type()       ? $::locale->text('The order has been deleted')
114            : $self->type eq purchase_order_type()    ? $::locale->text('The order has been deleted')
115            : $self->type eq sales_quotation_type()   ? $::locale->text('The quotation has been deleted')
116            : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been deleted')
117            : '';
118   flash_later('info', $text);
119
120   my @redirect_params = (
121     action => 'add',
122     type   => $self->type,
123   );
124
125   $self->redirect_to(@redirect_params);
126 }
127
128 # save the order
129 sub action_save {
130   my ($self) = @_;
131
132   my $errors = $self->save();
133
134   if (scalar @{ $errors }) {
135     $self->js->flash('error', $_) foreach @{ $errors };
136     return $self->js->render();
137   }
138
139   my $text = $self->type eq sales_order_type()       ? $::locale->text('The order has been saved')
140            : $self->type eq purchase_order_type()    ? $::locale->text('The order has been saved')
141            : $self->type eq sales_quotation_type()   ? $::locale->text('The quotation has been saved')
142            : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
143            : '';
144   flash_later('info', $text);
145
146   my @redirect_params = (
147     action => 'edit',
148     type   => $self->type,
149     id     => $self->order->id,
150   );
151
152   $self->redirect_to(@redirect_params);
153 }
154
155 # save the order as new document an open it for edit
156 sub action_save_as_new {
157   my ($self) = @_;
158
159   my $order = $self->order;
160
161   if (!$order->id) {
162     $self->js->flash('error', t8('This object has not been saved yet.'));
163     return $self->js->render();
164   }
165
166   # load order from db to check if values changed
167   my $saved_order = SL::DB::Order->new(id => $order->id)->load;
168
169   my %new_attrs;
170   # Lets assign a new number if the user hasn't changed the previous one.
171   # If it has been changed manually then use it as-is.
172   $new_attrs{number}    = (trim($order->number) eq $saved_order->number)
173                         ? ''
174                         : trim($order->number);
175
176   # Clear transdate unless changed
177   $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
178                         ? DateTime->today_local
179                         : $order->transdate;
180
181   # Set new reqdate unless changed
182   if ($order->reqdate == $saved_order->reqdate) {
183     my $extra_days = $self->type eq sales_quotation_type() ? $::instance_conf->get_reqdate_interval : 1;
184     $new_attrs{reqdate} = DateTime->today_local->next_workday(extra_days => $extra_days);
185   } else {
186     $new_attrs{reqdate} = $order->reqdate;
187   }
188
189   # Update employee
190   $new_attrs{employee}  = SL::DB::Manager::Employee->current;
191
192   # Create new record from current one
193   $self->order(SL::DB::Order->new_from($order, destination_type => $order->type, attributes => \%new_attrs));
194
195   # no linked records on save as new
196   delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
197
198   # save
199   $self->action_save();
200 }
201
202 # print the order
203 #
204 # This is called if "print" is pressed in the print dialog.
205 # If PDF creation was requested and succeeded, the pdf is stored in a session
206 # file and the filename is stored as session value with an unique key. A
207 # javascript function with this key is then called. This function calls the
208 # download action below (action_download_pdf), which offers the file for
209 # download.
210 sub action_print {
211   my ($self) = @_;
212
213   my $format      = $::form->{print_options}->{format};
214   my $media       = $::form->{print_options}->{media};
215   my $formname    = $::form->{print_options}->{formname};
216   my $copies      = $::form->{print_options}->{copies};
217   my $groupitems  = $::form->{print_options}->{groupitems};
218
219   # only pdf and opendocument by now
220   if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf)) {
221     return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
222   }
223
224   # only screen or printer by now
225   if (none { $media eq $_ } qw(screen printer)) {
226     return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
227   }
228
229   my $language;
230   $language = SL::DB::Language->new(id => $::form->{print_options}->{language_id})->load if $::form->{print_options}->{language_id};
231
232   # create a form for generate_attachment_filename
233   my $form   = Form->new;
234   $form->{$self->nr_key()}  = $self->order->number;
235   $form->{type}             = $self->type;
236   $form->{format}           = $format;
237   $form->{formname}         = $formname;
238   $form->{language}         = '_' . $language->template_code if $language;
239   my $pdf_filename          = $form->generate_attachment_filename();
240
241   my $pdf;
242   my @errors = generate_pdf($self->order, \$pdf, { format     => $format,
243                                                    formname   => $formname,
244                                                    language   => $language,
245                                                    groupitems => $groupitems });
246   if (scalar @errors) {
247     return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
248   }
249
250   if ($media eq 'screen') {
251     # screen/download
252     my $sfile = SL::SessionFile::Random->new(mode => "w");
253     $sfile->fh->print($pdf);
254     $sfile->fh->close;
255
256     my $key = join('_', Time::HiRes::gettimeofday(), int rand 1000000000000);
257     $::auth->set_session_value("Order::print-${key}" => $sfile->file_name);
258
259     $self->js
260     ->run('kivi.Order.download_pdf', $pdf_filename, $key)
261     ->flash('info', t8('The PDF has been created'));
262
263   } elsif ($media eq 'printer') {
264     # printer
265     my $printer_id = $::form->{print_options}->{printer_id};
266     SL::DB::Printer->new(id => $printer_id)->load->print_document(
267       copies  => $copies,
268       content => $pdf,
269     );
270
271     $self->js->flash('info', t8('The PDF has been printed'));
272   }
273
274   # copy file to webdav folder
275   if ($self->order->number && $::instance_conf->get_webdav_documents) {
276     my $webdav = SL::Webdav->new(
277       type     => $self->type,
278       number   => $self->order->number,
279     );
280     my $webdav_file = SL::Webdav::File->new(
281       webdav   => $webdav,
282       filename => $pdf_filename,
283     );
284     eval {
285       $webdav_file->store(data => \$pdf);
286       1;
287     } or do {
288       $self->js->flash('error', t8('Storing PDF to webdav folder failed: #1', $@));
289     }
290   }
291   if ($self->order->number && $::instance_conf->get_doc_storage) {
292     eval {
293       SL::File->save(object_id     => $self->order->id,
294                      object_type   => $self->type,
295                      mime_type     => 'application/pdf',
296                      source        => 'created',
297                      file_type     => 'document',
298                      file_name     => $pdf_filename,
299                      file_contents => $pdf);
300       1;
301     } or do {
302       $self->js->flash('error', t8('Storing PDF in storage backend failed: #1', $@));
303     }
304   }
305   $self->js->render;
306 }
307
308 # offer pdf for download
309 #
310 # It needs to get the key for the session value to get the pdf file.
311 sub action_download_pdf {
312   my ($self) = @_;
313
314   my $key = $::form->{key};
315   my $tmp_filename = $::auth->get_session_value("Order::print-${key}");
316   return $self->send_file(
317     $tmp_filename,
318     type => 'application/pdf',
319     name => $::form->{pdf_filename},
320   );
321 }
322
323 # open the email dialog
324 sub action_show_email_dialog {
325   my ($self) = @_;
326
327   my $cv_method = $self->cv;
328
329   if (!$self->order->$cv_method) {
330     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'))
331                     ->render($self);
332   }
333
334   my $email_form;
335   $email_form->{to}   = $self->order->contact->cp_email if $self->order->contact;
336   $email_form->{to} ||= $self->order->$cv_method->email;
337   $email_form->{cc}   = $self->order->$cv_method->cc;
338   $email_form->{bcc}  = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
339   # Todo: get addresses from shipto, if any
340
341   my $form = Form->new;
342   $form->{$self->nr_key()}  = $self->order->number;
343   $form->{formname}         = $self->type;
344   $form->{type}             = $self->type;
345   $form->{language}         = 'de';
346   $form->{format}           = 'pdf';
347
348   $email_form->{subject}             = $form->generate_email_subject();
349   $email_form->{attachment_filename} = $form->generate_attachment_filename();
350   $email_form->{message}             = $form->generate_email_body();
351   $email_form->{js_send_function}    = 'kivi.Order.send_email()';
352
353   my %files = $self->get_files_for_email_dialog();
354   my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
355                                   email_form  => $email_form,
356                                   show_bcc    => $::auth->assert('email_bcc', 'may fail'),
357                                   FILES       => \%files,
358                                   is_customer => $self->cv eq 'customer',
359   );
360
361   $self->js
362       ->run('kivi.Order.show_email_dialog', $dialog_html)
363       ->reinit_widgets
364       ->render($self);
365 }
366
367 # send email
368 #
369 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
370 sub action_send_email {
371   my ($self) = @_;
372
373   my $email_form  = delete $::form->{email_form};
374   my %field_names = (to => 'email');
375
376   $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
377
378   # for Form::cleanup which may be called in Form::send_email
379   $::form->{cwd}    = getcwd();
380   $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
381
382   $::form->{$_}     = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
383   $::form->{media}  = 'email';
384
385   if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) {
386     my $language;
387     $language = SL::DB::Language->new(id => $::form->{print_options}->{language_id})->load if $::form->{print_options}->{language_id};
388
389     my $pdf;
390     my @errors = generate_pdf($self->order, \$pdf, {media      => $::form->{media},
391                                                      format     => $::form->{print_options}->{format},
392                                                      formname   => $::form->{print_options}->{formname},
393                                                      language   => $language,
394                                                      groupitems => $::form->{print_options}->{groupitems}});
395     if (scalar @errors) {
396       return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
397     }
398
399     my $sfile = SL::SessionFile::Random->new(mode => "w");
400     $sfile->fh->print($pdf);
401     $sfile->fh->close;
402
403     $::form->{tmpfile} = $sfile->file_name;
404     $::form->{tmpdir}  = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
405   }
406
407   $::form->send_email(\%::myconfig, 'pdf');
408
409   # internal notes
410   my $intnotes = $self->order->intnotes;
411   $intnotes   .= "\n\n" if $self->order->intnotes;
412   $intnotes   .= t8('[email]')                                                                                        . "\n";
413   $intnotes   .= t8('Date')       . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
414   $intnotes   .= t8('To (email)') . ": " . $::form->{email}                                                           . "\n";
415   $intnotes   .= t8('Cc')         . ": " . $::form->{cc}                                                              . "\n"    if $::form->{cc};
416   $intnotes   .= t8('Bcc')        . ": " . $::form->{bcc}                                                             . "\n"    if $::form->{bcc};
417   $intnotes   .= t8('Subject')    . ": " . $::form->{subject}                                                         . "\n\n";
418   $intnotes   .= t8('Message')    . ": " . $::form->{message};
419
420   $self->js
421       ->val('#order_intnotes', $intnotes)
422       ->run('kivi.Order.close_email_dialog')
423       ->flash('info', t8('The email has been sent.'))
424       ->render($self);
425 }
426
427 # open the periodic invoices config dialog
428 #
429 # If there are values in the form (i.e. dialog was opened before),
430 # then use this values. Create new ones, else.
431 sub action_show_periodic_invoices_config_dialog {
432   my ($self) = @_;
433
434   my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
435   $config  ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
436   $config  ||= SL::DB::PeriodicInvoicesConfig->new(periodicity             => 'm',
437                                                    order_value_periodicity => 'p', # = same as periodicity
438                                                    start_date_as_date      => $::form->{transdate} || $::form->current_date,
439                                                    extend_automatically_by => 12,
440                                                    active                  => 1,
441                                                    email_subject           => GenericTranslations->get(
442                                                                                 language_id      => $::form->{language_id},
443                                                                                 translation_type =>"preset_text_periodic_invoices_email_subject"),
444                                                    email_body              => GenericTranslations->get(
445                                                                                 language_id      => $::form->{language_id},
446                                                                                 translation_type =>"preset_text_periodic_invoices_email_body"),
447   );
448   $config->periodicity('m')             if none { $_ eq $config->periodicity             }       @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
449   $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
450
451   $::form->get_lists(printers => "ALL_PRINTERS",
452                      charts   => { key       => 'ALL_CHARTS',
453                                    transdate => 'current_date' });
454
455   $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
456
457   if ($::form->{customer_id}) {
458     $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
459   }
460
461   $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
462                 popup_dialog             => 1,
463                 popup_js_close_function  => 'kivi.Order.close_periodic_invoices_config_dialog()',
464                 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
465                 config                   => $config,
466                 %$::form);
467 }
468
469 # assign the values of the periodic invoices config dialog
470 # as yaml in the hidden tag and set the status.
471 sub action_assign_periodic_invoices_config {
472   my ($self) = @_;
473
474   $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
475
476   my $config = { active                     => $::form->{active}       ? 1 : 0,
477                  terminated                 => $::form->{terminated}   ? 1 : 0,
478                  direct_debit               => $::form->{direct_debit} ? 1 : 0,
479                  periodicity                => (any { $_ eq $::form->{periodicity}             }       @SL::DB::PeriodicInvoicesConfig::PERIODICITIES)              ? $::form->{periodicity}             : 'm',
480                  order_value_periodicity    => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
481                  start_date_as_date         => $::form->{start_date_as_date},
482                  end_date_as_date           => $::form->{end_date_as_date},
483                  first_billing_date_as_date => $::form->{first_billing_date_as_date},
484                  print                      => $::form->{print}      ? 1                         : 0,
485                  printer_id                 => $::form->{print}      ? $::form->{printer_id} * 1 : undef,
486                  copies                     => $::form->{copies} * 1 ? $::form->{copies}         : 1,
487                  extend_automatically_by    => $::form->{extend_automatically_by}    * 1 || undef,
488                  ar_chart_id                => $::form->{ar_chart_id} * 1,
489                  send_email                 => $::form->{send_email} ? 1 : 0,
490                  email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
491                  email_recipient_address    => $::form->{email_recipient_address},
492                  email_sender               => $::form->{email_sender},
493                  email_subject              => $::form->{email_subject},
494                  email_body                 => $::form->{email_body},
495                };
496
497   my $periodic_invoices_config = YAML::Dump($config);
498
499   my $status = $self->get_periodic_invoices_status($config);
500
501   $self->js
502     ->remove('#order_periodic_invoices_config')
503     ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
504     ->run('kivi.Order.close_periodic_invoices_config_dialog')
505     ->html('#periodic_invoices_status', $status)
506     ->flash('info', t8('The periodic invoices config has been assigned.'))
507     ->render($self);
508 }
509
510 sub action_get_has_active_periodic_invoices {
511   my ($self) = @_;
512
513   my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
514   $config  ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
515
516   my $has_active_periodic_invoices =
517        $self->type eq sales_order_type()
518     && $config
519     && $config->active
520     && (!$config->end_date || ($config->end_date > DateTime->today_local))
521     && $config->get_previous_billed_period_start_date;
522
523   $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
524 }
525
526 # save the order and redirect to the frontend subroutine for a new
527 # delivery order
528 sub action_save_and_delivery_order {
529   my ($self) = @_;
530
531   my $errors = $self->save();
532
533   if (scalar @{ $errors }) {
534     $self->js->flash('error', $_) foreach @{ $errors };
535     return $self->js->render();
536   }
537
538   my $text = $self->type eq sales_order_type()       ? $::locale->text('The order has been saved')
539            : $self->type eq purchase_order_type()    ? $::locale->text('The order has been saved')
540            : $self->type eq sales_quotation_type()   ? $::locale->text('The quotation has been saved')
541            : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
542            : '';
543   flash_later('info', $text);
544
545   my @redirect_params = (
546     controller => 'oe.pl',
547     action     => 'oe_delivery_order_from_order',
548     id         => $self->order->id,
549   );
550
551   $self->redirect_to(@redirect_params);
552 }
553
554 # save the order and redirect to the frontend subroutine for a new
555 # invoice
556 sub action_save_and_invoice {
557   my ($self) = @_;
558
559   my $errors = $self->save();
560
561   if (scalar @{ $errors }) {
562     $self->js->flash('error', $_) foreach @{ $errors };
563     return $self->js->render();
564   }
565
566   my $text = $self->type eq sales_order_type()       ? $::locale->text('The order has been saved')
567            : $self->type eq purchase_order_type()    ? $::locale->text('The order has been saved')
568            : $self->type eq sales_quotation_type()   ? $::locale->text('The quotation has been saved')
569            : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
570            : '';
571   flash_later('info', $text);
572
573   my @redirect_params = (
574     controller => 'oe.pl',
575     action     => 'oe_invoice_from_order',
576     id         => $self->order->id,
577   );
578
579   $self->redirect_to(@redirect_params);
580 }
581
582 # workflow from sales quotation to sales order
583 sub action_sales_order {
584   $_[0]->workflow_sales_or_purchase_order();
585 }
586
587 # workflow from rfq to purchase order
588 sub action_purchase_order {
589   $_[0]->workflow_sales_or_purchase_order();
590 }
591
592 # set form elements in respect to a changed customer or vendor
593 #
594 # This action is called on an change of the customer/vendor picker.
595 sub action_customer_vendor_changed {
596   my ($self) = @_;
597
598   setup_order_from_cv($self->order);
599   $self->recalc();
600
601   my $cv_method = $self->cv;
602
603   if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
604     $self->js->show('#cp_row');
605   } else {
606     $self->js->hide('#cp_row');
607   }
608
609   if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
610     $self->js->show('#shipto_row');
611   } else {
612     $self->js->hide('#shipto_row');
613   }
614
615   $self->js->val( '#order_salesman_id',      $self->order->salesman_id)        if $self->order->is_sales;
616
617   $self->js
618     ->replaceWith('#order_cp_id',            $self->build_contact_select)
619     ->replaceWith('#order_shipto_id',        $self->build_shipto_select)
620     ->replaceWith('#business_info_row',      $self->build_business_info_row)
621     ->val(        '#order_taxzone_id',       $self->order->taxzone_id)
622     ->val(        '#order_taxincluded',      $self->order->taxincluded)
623     ->val(        '#order_payment_id',       $self->order->payment_id)
624     ->val(        '#order_delivery_term_id', $self->order->delivery_term_id)
625     ->val(        '#order_intnotes',         $self->order->intnotes)
626     ->focus(      '#order_' . $self->cv . '_id');
627
628   $self->js_redisplay_amounts_and_taxes;
629   $self->js->render();
630 }
631
632 # open the dialog for customer/vendor details
633 sub action_show_customer_vendor_details_dialog {
634   my ($self) = @_;
635
636   my $is_customer = 'customer' eq $::form->{vc};
637   my $cv;
638   if ($is_customer) {
639     $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
640   } else {
641     $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
642   }
643
644   my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
645   $details{discount_as_percent} = $cv->discount_as_percent;
646   $details{creditlimt}          = $cv->creditlimit_as_number;
647   $details{business}            = $cv->business->description      if $cv->business;
648   $details{language}            = $cv->language_obj->description  if $cv->language_obj;
649   $details{delivery_terms}      = $cv->delivery_term->description if $cv->delivery_term;
650   $details{payment_terms}       = $cv->payment->description       if $cv->payment;
651   $details{pricegroup}          = $cv->pricegroup->pricegroup     if $is_customer && $cv->pricegroup;
652
653   foreach my $entry (@{ $cv->shipto }) {
654     push @{ $details{SHIPTO} },   { map { $_ => $entry->$_ } @{$entry->meta->columns} };
655   }
656   foreach my $entry (@{ $cv->contacts }) {
657     push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
658   }
659
660   $_[0]->render('common/show_vc_details', { layout => 0 },
661                 is_customer => $is_customer,
662                 %details);
663
664 }
665
666 # called if a unit in an existing item row is changed
667 sub action_unit_changed {
668   my ($self) = @_;
669
670   my $idx  = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
671   my $item = $self->order->items_sorted->[$idx];
672
673   my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
674   $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
675
676   $self->recalc();
677
678   $self->js
679     ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
680   $self->js_redisplay_line_values;
681   $self->js_redisplay_amounts_and_taxes;
682   $self->js->render();
683 }
684
685 # add an item row for a new item entered in the input row
686 sub action_add_item {
687   my ($self) = @_;
688
689   my $form_attr = $::form->{add_item};
690
691   return unless $form_attr->{parts_id};
692
693   my $item = new_item($self->order, $form_attr);
694
695   $self->order->add_items($item);
696
697   $self->recalc();
698
699   my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
700   my $row_as_html = $self->p->render('order/tabs/_row',
701                                      ITEM              => $item,
702                                      ID                => $item_id,
703                                      TYPE              => $self->type,
704                                      ALL_PRICE_FACTORS => $self->all_price_factors
705   );
706
707   $self->js
708     ->append('#row_table_id', $row_as_html);
709
710   if ( $item->part->is_assortment ) {
711     $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
712     foreach my $assortment_item ( @{$item->part->assortment_items} ) {
713       my $attr = { parts_id => $assortment_item->parts_id,
714                    qty      => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
715                    unit     => $assortment_item->unit,
716                    description => $assortment_item->part->description,
717                  };
718       my $item = new_item($self->order, $attr);
719
720       # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
721       $item->discount(1) unless $assortment_item->charge;
722
723       $self->order->add_items( $item );
724       $self->recalc();
725       my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
726       my $row_as_html = $self->p->render('order/tabs/_row',
727                                          ITEM              => $item,
728                                          ID                => $item_id,
729                                          TYPE              => $self->type,
730                                          ALL_PRICE_FACTORS => $self->all_price_factors
731       );
732       $self->js
733         ->append('#row_table_id', $row_as_html);
734     };
735   };
736
737   $self->js
738     ->val('.add_item_input', '')
739     ->run('kivi.Order.init_row_handlers')
740     ->run('kivi.Order.row_table_scroll_down')
741     ->run('kivi.Order.renumber_positions')
742     ->focus('#add_item_parts_id_name');
743
744   $self->js_redisplay_amounts_and_taxes;
745   $self->js->render();
746 }
747
748 # open the dialog for entering multiple items at once
749 sub action_show_multi_items_dialog {
750   $_[0]->render('order/tabs/_multi_items_dialog', { layout => 0 },
751                 all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
752 }
753
754 # update the filter results in the multi item dialog
755 sub action_multi_items_update_result {
756   my $max_count = 100;
757
758   $::form->{multi_items}->{filter}->{obsolete} = 0;
759
760   my $count = $_[0]->multi_items_models->count;
761
762   if ($count == 0) {
763     my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
764     $_[0]->render($text, { layout => 0 });
765   } elsif ($count > $max_count) {
766     my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
767     $_[0]->render($text, { layout => 0 });
768   } else {
769     my $multi_items = $_[0]->multi_items_models->get;
770     $_[0]->render('order/tabs/_multi_items_result', { layout => 0 },
771                   multi_items => $multi_items);
772   }
773 }
774
775 # add item rows for multiple items at once
776 sub action_add_multi_items {
777   my ($self) = @_;
778
779   my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_multi_items} };
780   return $self->js->render() unless scalar @form_attr;
781
782   my @items;
783   foreach my $attr (@form_attr) {
784     my $item = new_item($self->order, $attr);
785     push @items, $item;
786     if ( $item->part->is_assortment ) {
787       foreach my $assortment_item ( @{$item->part->assortment_items} ) {
788         my $attr = { parts_id => $assortment_item->parts_id,
789                      qty      => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
790                      unit     => $assortment_item->unit,
791                      description => $assortment_item->part->description,
792                    };
793         my $item = new_item($self->order, $attr);
794
795         # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
796         $item->discount(1) unless $assortment_item->charge;
797         push @items, $item;
798       }
799     }
800   }
801   $self->order->add_items(@items);
802
803   $self->recalc();
804
805   foreach my $item (@items) {
806     my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
807     my $row_as_html = $self->p->render('order/tabs/_row',
808                                        ITEM              => $item,
809                                        ID                => $item_id,
810                                        TYPE              => $self->type,
811                                        ALL_PRICE_FACTORS => $self->all_price_factors
812     );
813
814     $self->js->append('#row_table_id', $row_as_html);
815   }
816
817   $self->js
818     ->run('kivi.Order.close_multi_items_dialog')
819     ->run('kivi.Order.init_row_handlers')
820     ->run('kivi.Order.row_table_scroll_down')
821     ->run('kivi.Order.renumber_positions')
822     ->focus('#add_item_parts_id_name');
823
824   $self->js_redisplay_amounts_and_taxes;
825   $self->js->render();
826 }
827
828 # recalculate all linetotals, amounts and taxes and redisplay them
829 sub action_recalc_amounts_and_taxes {
830   my ($self) = @_;
831
832   $self->recalc();
833
834   $self->js_redisplay_line_values;
835   $self->js_redisplay_amounts_and_taxes;
836   $self->js->render();
837 }
838
839 # redisplay item rows if they are sorted by an attribute
840 sub action_reorder_items {
841   my ($self) = @_;
842
843   my %sort_keys = (
844     partnumber  => sub { $_[0]->part->partnumber },
845     description => sub { $_[0]->description },
846     qty         => sub { $_[0]->qty },
847     sellprice   => sub { $_[0]->sellprice },
848     discount    => sub { $_[0]->discount },
849   );
850
851   my $method = $sort_keys{$::form->{order_by}};
852   my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
853   if ($::form->{sort_dir}) {
854     @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
855   } else {
856     @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
857   }
858   $self->js
859     ->run('kivi.Order.redisplay_items', \@to_sort)
860     ->render;
861 }
862
863 # show the popup to choose a price/discount source
864 sub action_price_popup {
865   my ($self) = @_;
866
867   my $idx  = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
868   my $item = $self->order->items_sorted->[$idx];
869
870   $self->render_price_dialog($item);
871 }
872
873 # get the longdescription for an item if the dialog to enter/change the
874 # longdescription was opened and the longdescription is empty
875 #
876 # If this item is new, get the longdescription from Part.
877 # Otherwise get it from OrderItem.
878 sub action_get_item_longdescription {
879   my $longdescription;
880
881   if ($::form->{item_id}) {
882     $longdescription = SL::DB::OrderItem->new(id => $::form->{item_id})->load->longdescription;
883   } elsif ($::form->{parts_id}) {
884     $longdescription = SL::DB::Part->new(id => $::form->{parts_id})->load->notes;
885   }
886   $_[0]->render(\ $longdescription, { type => 'text' });
887 }
888
889 # load the second row for one or more items
890 #
891 # This action gets the html code for all items second rows by rendering a template for
892 # the second row and sets the html code via client js.
893 sub action_load_second_rows {
894   my ($self) = @_;
895
896   $self->recalc() if $self->order->is_sales; # for margin calculation
897
898   foreach my $item_id (@{ $::form->{item_ids} }) {
899     my $idx  = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
900     my $item = $self->order->items_sorted->[$idx];
901
902     $self->js_load_second_row($item, $item_id, 0);
903   }
904
905   $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
906
907   $self->js->render();
908 }
909
910 sub js_load_second_row {
911   my ($self, $item, $item_id, $do_parse) = @_;
912
913   if ($do_parse) {
914     # Parse values from form (they are formated while rendering (template)).
915     # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
916     # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
917     foreach my $var (@{ $item->cvars_by_config }) {
918       $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
919     }
920     $item->parse_custom_variable_values;
921   }
922
923   my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
924
925   $self->js
926     ->html('.row_entry:has(#item_' . $item_id . ') [name = "second_row"]', $row_as_html)
927     ->data('.row_entry:has(#item_' . $item_id . ') [name = "second_row"]', 'loaded', 1);
928 }
929
930 sub js_redisplay_line_values {
931   my ($self) = @_;
932
933   my $is_sales = $self->order->is_sales;
934
935   # sales orders with margins
936   my @data;
937   if ($is_sales) {
938     @data = map {
939       [
940        $::form->format_amount(\%::myconfig, $_->{linetotal},     2, 0),
941        $::form->format_amount(\%::myconfig, $_->{marge_total},   2, 0),
942        $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
943       ]} @{ $self->order->items_sorted };
944   } else {
945     @data = map {
946       [
947        $::form->format_amount(\%::myconfig, $_->{linetotal},     2, 0),
948       ]} @{ $self->order->items_sorted };
949   }
950
951   $self->js
952     ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
953 }
954
955 sub js_redisplay_amounts_and_taxes {
956   my ($self) = @_;
957
958   if (scalar @{ $self->{taxes} }) {
959     $self->js->show('#taxincluded_row_id');
960   } else {
961     $self->js->hide('#taxincluded_row_id');
962   }
963
964   if ($self->order->taxincluded) {
965     $self->js->hide('#subtotal_row_id');
966   } else {
967     $self->js->show('#subtotal_row_id');
968   }
969
970   $self->js
971     ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
972     ->html('#amount_id',    $::form->format_amount(\%::myconfig, $self->order->amount,    -2))
973     ->remove('.tax_row')
974     ->insertBefore($self->build_tax_rows, '#amount_row_id');
975 }
976
977 #
978 # helpers
979 #
980
981 sub init_valid_types {
982   [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
983 }
984
985 sub init_type {
986   my ($self) = @_;
987
988   if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
989     die "Not a valid type for order";
990   }
991
992   $self->type($::form->{type});
993 }
994
995 sub init_cv {
996   my ($self) = @_;
997
998   my $cv = (any { $self->type eq $_ } (sales_order_type(),    sales_quotation_type()))   ? 'customer'
999          : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
1000          : die "Not a valid type for order";
1001
1002   return $cv;
1003 }
1004
1005 sub init_p {
1006   SL::Presenter->get;
1007 }
1008
1009 sub init_order {
1010   $_[0]->make_order;
1011 }
1012
1013 # model used to filter/display the parts in the multi-items dialog
1014 sub init_multi_items_models {
1015   SL::Controller::Helper::GetModels->new(
1016     controller     => $_[0],
1017     model          => 'Part',
1018     with_objects   => [ qw(unit_obj) ],
1019     disable_plugin => 'paginated',
1020     source         => $::form->{multi_items},
1021     sorted         => {
1022       _default    => {
1023         by  => 'partnumber',
1024         dir => 1,
1025       },
1026       partnumber  => t8('Partnumber'),
1027       description => t8('Description')}
1028   );
1029 }
1030
1031 sub init_all_price_factors {
1032   SL::DB::Manager::PriceFactor->get_all;
1033 }
1034
1035 sub check_auth {
1036   my ($self) = @_;
1037
1038   my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
1039
1040   my $right   = $right_for->{ $self->type };
1041   $right    ||= 'DOES_NOT_EXIST';
1042
1043   $::auth->assert($right);
1044 }
1045
1046 # build the selection box for contacts
1047 #
1048 # Needed, if customer/vendor changed.
1049 sub build_contact_select {
1050   my ($self) = @_;
1051
1052   select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1053     value_key  => 'cp_id',
1054     title_key  => 'full_name_dep',
1055     default    => $self->order->cp_id,
1056     with_empty => 1,
1057     style      => 'width: 300px',
1058   );
1059 }
1060
1061 # build the selection box for shiptos
1062 #
1063 # Needed, if customer/vendor changed.
1064 sub build_shipto_select {
1065   my ($self) = @_;
1066
1067   select_tag('order.shipto_id', [ $self->order->{$self->cv}->shipto ],
1068     value_key  => 'shipto_id',
1069     title_key  => 'displayable_id',
1070     default    => $self->order->shipto_id,
1071     with_empty => 1,
1072     style      => 'width: 300px',
1073   );
1074 }
1075
1076 # render the info line for business
1077 #
1078 # Needed, if customer/vendor changed.
1079 sub build_business_info_row
1080 {
1081   $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
1082 }
1083
1084 # build the rows for displaying taxes
1085 #
1086 # Called if amounts where recalculated and redisplayed.
1087 sub build_tax_rows {
1088   my ($self) = @_;
1089
1090   my $rows_as_html;
1091   foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
1092     $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
1093   }
1094   return $rows_as_html;
1095 }
1096
1097
1098 sub render_price_dialog {
1099   my ($self, $record_item) = @_;
1100
1101   my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1102
1103   $self->js
1104     ->run(
1105       'kivi.io.price_chooser_dialog',
1106       t8('Available Prices'),
1107       $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1108     )
1109     ->reinit_widgets;
1110
1111 #   if (@errors) {
1112 #     $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1113 #     $self->js->show('#dialog_flash_error');
1114 #   }
1115
1116   $self->js->render;
1117 }
1118
1119 sub load_order {
1120   my ($self) = @_;
1121
1122   return if !$::form->{id};
1123
1124   $self->order(SL::DB::Order->new(id => $::form->{id})->load);
1125 }
1126
1127 # load or create a new order object
1128 #
1129 # And assign changes from the form to this object.
1130 # If the order is loaded from db, check if items are deleted in the form,
1131 # remove them form the object and collect them for removing from db on saving.
1132 # Then create/update items from form (via make_item) and add them.
1133 sub make_order {
1134   my ($self) = @_;
1135
1136   # add_items adds items to an order with no items for saving, but they cannot
1137   # be retrieved via items until the order is saved. Adding empty items to new
1138   # order here solves this problem.
1139   my $order;
1140   $order   = SL::DB::Manager::Order->find_by(id => $::form->{id}) if $::form->{id};
1141   $order ||= SL::DB::Order->new(orderitems => [],
1142                                 quotation  => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())));
1143
1144   my $cv_id_method = $self->cv . '_id';
1145   if (!$::form->{id} && $::form->{$cv_id_method}) {
1146     $order->$cv_id_method($::form->{$cv_id_method});
1147     setup_order_from_cv($order);
1148   }
1149
1150   my $form_orderitems               = delete $::form->{order}->{orderitems};
1151   my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1152
1153   $order->assign_attributes(%{$::form->{order}});
1154
1155   my $periodic_invoices_config = make_periodic_invoices_config_from_yaml($form_periodic_invoices_config);
1156   $order->periodic_invoices_config($periodic_invoices_config) if $periodic_invoices_config;
1157
1158   # remove deleted items
1159   $self->item_ids_to_delete([]);
1160   foreach my $idx (reverse 0..$#{$order->orderitems}) {
1161     my $item = $order->orderitems->[$idx];
1162     if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1163       splice @{$order->orderitems}, $idx, 1;
1164       push @{$self->item_ids_to_delete}, $item->id;
1165     }
1166   }
1167
1168   my @items;
1169   my $pos = 1;
1170   foreach my $form_attr (@{$form_orderitems}) {
1171     my $item = make_item($order, $form_attr);
1172     $item->position($pos);
1173     push @items, $item;
1174     $pos++;
1175   }
1176   $order->add_items(grep {!$_->id} @items);
1177
1178   return $order;
1179 }
1180
1181 # create or update items from form
1182 #
1183 # Make item objects from form values. For items already existing read from db.
1184 # Create a new item else. And assign attributes.
1185 sub make_item {
1186   my ($record, $attr) = @_;
1187
1188   my $item;
1189   $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1190
1191   my $is_new = !$item;
1192
1193   # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1194   # they cannot be retrieved via custom_variables until the order/orderitem is
1195   # saved. Adding empty custom_variables to new orderitem here solves this problem.
1196   $item ||= SL::DB::OrderItem->new(custom_variables => []);
1197
1198   $item->assign_attributes(%$attr);
1199   $item->longdescription($item->part->notes)                     if $is_new && !defined $attr->{longdescription};
1200   $item->project_id($record->globalproject_id)                   if $is_new && !defined $attr->{project_id};
1201   $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if $is_new && !defined $attr->{lastcost_as_number};
1202
1203   return $item;
1204 }
1205
1206 # create a new item
1207 #
1208 # This is used to add one item
1209 sub new_item {
1210   my ($record, $attr) = @_;
1211
1212   my $item = SL::DB::OrderItem->new;
1213
1214   # Remove attributes where the user left or set the inputs empty.
1215   # So these attributes will be undefined and we can distinguish them
1216   # from zero later on.
1217   for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1218     delete $attr->{$_} if $attr->{$_} eq '';
1219   }
1220
1221   $item->assign_attributes(%$attr);
1222
1223   my $part         = SL::DB::Part->new(id => $attr->{parts_id})->load;
1224   my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1225
1226   $item->unit($part->unit) if !$item->unit;
1227
1228   my $price_src;
1229   if ( $part->is_assortment ) {
1230     # add assortment items with price 0, as the components carry the price
1231     $price_src = $price_source->price_from_source("");
1232     $price_src->price(0);
1233   } elsif (defined $item->sellprice) {
1234     $price_src = $price_source->price_from_source("");
1235     $price_src->price($item->sellprice);
1236   } else {
1237     $price_src = $price_source->best_price
1238            ? $price_source->best_price
1239            : $price_source->price_from_source("");
1240     $price_src->price(0) if !$price_source->best_price;
1241   }
1242
1243   my $discount_src;
1244   if (defined $item->discount) {
1245     $discount_src = $price_source->discount_from_source("");
1246     $discount_src->discount($item->discount);
1247   } else {
1248     $discount_src = $price_source->best_discount
1249                   ? $price_source->best_discount
1250                   : $price_source->discount_from_source("");
1251     $discount_src->discount(0) if !$price_source->best_discount;
1252   }
1253
1254   my %new_attr;
1255   $new_attr{part}                   = $part;
1256   $new_attr{description}            = $part->description     if ! $item->description;
1257   $new_attr{qty}                    = 1.0                    if ! $item->qty;
1258   $new_attr{price_factor_id}        = $part->price_factor_id if ! $item->price_factor_id;
1259   $new_attr{sellprice}              = $price_src->price;
1260   $new_attr{discount}               = $discount_src->discount;
1261   $new_attr{active_price_source}    = $price_src;
1262   $new_attr{active_discount_source} = $discount_src;
1263   $new_attr{longdescription}        = $part->notes           if ! defined $attr->{longdescription};
1264   $new_attr{project_id}             = $record->globalproject_id;
1265   $new_attr{lastcost}               = $record->is_sales ? $part->lastcost : 0;
1266
1267   # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1268   # they cannot be retrieved via custom_variables until the order/orderitem is
1269   # saved. Adding empty custom_variables to new orderitem here solves this problem.
1270   $new_attr{custom_variables} = [];
1271
1272   $item->assign_attributes(%new_attr);
1273
1274   return $item;
1275 }
1276
1277 sub setup_order_from_cv {
1278   my ($order) = @_;
1279
1280   $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id));
1281
1282   $order->intnotes($order->customervendor->notes);
1283
1284   if ($order->is_sales) {
1285     $order->salesman_id($order->customer->salesman_id);
1286     $order->taxincluded(defined($order->customer->taxincluded_checked)
1287                         ? $order->customer->taxincluded_checked
1288                         : $::myconfig{taxincluded_checked});
1289   }
1290
1291 }
1292
1293 # recalculate prices and taxes
1294 #
1295 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1296 sub recalc {
1297   my ($self) = @_;
1298
1299   # bb: todo: currency later
1300   $self->order->currency_id($::instance_conf->get_currency_id());
1301
1302   my %pat = $self->order->calculate_prices_and_taxes();
1303   $self->{taxes} = [];
1304   foreach my $tax_chart_id (keys %{ $pat{taxes} }) {
1305     my $tax = SL::DB::Manager::Tax->find_by(chart_id => $tax_chart_id);
1306
1307     my @amount_keys = grep { $pat{amounts}->{$_}->{tax_id} == $tax->id } keys %{ $pat{amounts} };
1308     push(@{ $self->{taxes} }, { amount    => $pat{taxes}->{$tax_chart_id},
1309                                 netamount => $pat{amounts}->{$amount_keys[0]}->{amount},
1310                                 tax       => $tax });
1311   }
1312
1313   pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items}, @{$pat{items}};
1314 }
1315
1316 # get data for saving, printing, ..., that is not changed in the form
1317 #
1318 # Only cvars for now.
1319 sub get_unalterable_data {
1320   my ($self) = @_;
1321
1322   foreach my $item (@{ $self->order->items }) {
1323     # autovivify all cvars that are not in the form (cvars_by_config can do it).
1324     # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1325     foreach my $var (@{ $item->cvars_by_config }) {
1326       $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1327     }
1328     $item->parse_custom_variable_values;
1329   }
1330 }
1331
1332 # delete the order
1333 #
1334 # And remove related files in the spool directory
1335 sub delete {
1336   my ($self) = @_;
1337
1338   my $errors = [];
1339   my $db     = $self->order->db;
1340
1341   $db->with_transaction(
1342     sub {
1343       my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1344       $self->order->delete;
1345       my $spool = $::lx_office_conf{paths}->{spool};
1346       unlink map { "$spool/$_" } @spoolfiles if $spool;
1347
1348       1;
1349   }) || push(@{$errors}, $db->error);
1350
1351   return $errors;
1352 }
1353
1354 # save the order
1355 #
1356 # And delete items that are deleted in the form.
1357 sub save {
1358   my ($self) = @_;
1359
1360   my $errors = [];
1361   my $db     = $self->order->db;
1362
1363   $db->with_transaction(sub {
1364     SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete};
1365     $self->order->save(cascade => 1);
1366
1367     # link records
1368     if ($::form->{converted_from_oe_id}) {
1369       SL::DB::Order->new(id => $::form->{converted_from_oe_id})->load->link_to_record($self->order);
1370
1371       if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1372         my $idx = 0;
1373         foreach (@{ $self->order->items_sorted }) {
1374           my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1375           next if !$from_id;
1376           SL::DB::RecordLink->new(from_table => 'orderitems',
1377                                   from_id    => $from_id,
1378                                   to_table   => 'orderitems',
1379                                   to_id      => $_->id
1380           )->save;
1381           $idx++;
1382         }
1383       }
1384     }
1385     1;
1386   }) || push(@{$errors}, $db->error);
1387
1388   return $errors;
1389 }
1390
1391 sub workflow_sales_or_purchase_order {
1392   my ($self) = @_;
1393
1394   my $destination_type = $::form->{type} eq sales_quotation_type()   ? sales_order_type()
1395                        : $::form->{type} eq request_quotation_type() ? purchase_order_type()
1396                        : $::form->{type} eq purchase_order_type()    ? sales_order_type()
1397                        : $::form->{type} eq sales_order_type()       ? purchase_order_type()
1398                        : '';
1399
1400   $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1401   $self->{converted_from_oe_id} = delete $::form->{id};
1402
1403   # set item ids to new fake id, to identify them as new items
1404   foreach my $item (@{$self->order->items_sorted}) {
1405     $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1406   }
1407
1408   # change form type
1409   $::form->{type} = $destination_type;
1410   $self->type($self->init_type);
1411   $self->cv  ($self->init_cv);
1412   $self->check_auth;
1413
1414   $self->recalc();
1415   $self->get_unalterable_data();
1416   $self->pre_render();
1417
1418   # trigger rendering values for second row/longdescription as hidden,
1419   # because they are loaded only on demand. So we need to keep the values
1420   # from the source.
1421   $_->{render_second_row}      = 1 for @{ $self->order->items_sorted };
1422   $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
1423
1424   $self->render(
1425     'order/form',
1426     title => $self->get_title_for('edit'),
1427     %{$self->{template_args}}
1428   );
1429 }
1430
1431
1432 sub pre_render {
1433   my ($self) = @_;
1434
1435   $self->{all_taxzones}             = SL::DB::Manager::TaxZone->get_all_sorted();
1436   $self->{all_departments}          = SL::DB::Manager::Department->get_all_sorted();
1437   $self->{all_employees}            = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1438                                                                                             deleted => 0 ] ],
1439                                                                          sort_by => 'name');
1440   $self->{all_salesmen}             = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1441                                                                                             deleted => 0 ] ],
1442                                                                          sort_by => 'name');
1443   $self->{all_payment_terms}        = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1444                                                                                                       obsolete => 0 ] ]);
1445   $self->{all_delivery_terms}       = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1446   $self->{current_employee_id}      = SL::DB::Manager::Employee->current->id;
1447   $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
1448   $self->{order_probabilities}      = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1449
1450   my $print_form = Form->new('');
1451   $print_form->{type}      = $self->type;
1452   $print_form->{printers}  = SL::DB::Manager::Printer->get_all_sorted;
1453   $print_form->{languages} = SL::DB::Manager::Language->get_all_sorted;
1454   $self->{print_options}   = SL::Helper::PrintOptions->get_print_options(
1455     form => $print_form,
1456     options => {dialog_name_prefix => 'print_options.',
1457                 show_headers       => 1,
1458                 no_queue           => 1,
1459                 no_postscript      => 1,
1460                 no_opendocument    => 0,
1461                 no_html            => 1},
1462   );
1463
1464   foreach my $item (@{$self->order->orderitems}) {
1465     my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1466     $item->active_price_source(   $price_source->price_from_source(   $item->active_price_source   ));
1467     $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1468   }
1469
1470   if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
1471     # calculate shipped qtys here to prevent calling calculate for every item via the items method
1472     SL::Helper::ShippedQty->new->calculate($self->order)->write_to_objects;
1473   }
1474
1475   if ($self->order->number && $::instance_conf->get_webdav) {
1476     my $webdav = SL::Webdav->new(
1477       type     => $self->type,
1478       number   => $self->order->number,
1479     );
1480     my @all_objects = $webdav->get_all_objects;
1481     @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1482                                                     type => t8('File'),
1483                                                     link => File::Spec->catfile($_->full_filedescriptor),
1484                                                 } } @all_objects;
1485   }
1486
1487   $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery edit_periodic_invoices_config calculate_qty);
1488   $self->setup_edit_action_bar;
1489 }
1490
1491 sub setup_edit_action_bar {
1492   my ($self, %params) = @_;
1493
1494   my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
1495                       || (($self->type eq sales_order_type())    && $::instance_conf->get_sales_order_show_delete)
1496                       || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
1497
1498   for my $bar ($::request->layout->get('actionbar')) {
1499     $bar->add(
1500       combobox => [
1501         action => [
1502           t8('Save'),
1503           call      => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1504                                                     $::instance_conf->get_order_warn_no_deliverydate,
1505                                                                                                       ],
1506           checks    => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1507         ],
1508         action => [
1509           t8('Save as new'),
1510           call      => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1511           checks    => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1512           disabled  => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1513         ],
1514         action => [
1515           t8('Save and Delivery Order'),
1516           call      => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1517                                                                        $::instance_conf->get_order_warn_no_deliverydate,
1518                                                                                                                         ],
1519           checks    => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1520           only_if   => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type()))
1521         ],
1522         action => [
1523           t8('Save and Invoice'),
1524           call      => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1525           checks    => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1526         ],
1527       ], # end of combobox "Save"
1528
1529       combobox => [
1530         action => [
1531           t8('Workflow'),
1532         ],
1533         action => [
1534           t8('Sales Order'),
1535           submit   => [ '#order_form', { action => "Order/sales_order" } ],
1536           only_if  => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
1537           disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1538         ],
1539         action => [
1540           t8('Purchase Order'),
1541           submit   => [ '#order_form', { action => "Order/purchase_order" } ],
1542           only_if  => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
1543           disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1544         ],
1545       ], # end of combobox "Workflow"
1546
1547       combobox => [
1548         action => [
1549           t8('Export'),
1550         ],
1551         action => [
1552           t8('Print'),
1553           call => [ 'kivi.Order.show_print_options' ],
1554         ],
1555         action => [
1556           t8('E-mail'),
1557           call => [ 'kivi.Order.email' ],
1558         ],
1559         action => [
1560           t8('Download attachments of all parts'),
1561           call     => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1562           disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1563           only_if  => $::instance_conf->get_doc_storage,
1564         ],
1565       ], # end of combobox "Export"
1566
1567       action => [
1568         t8('Delete'),
1569         call     => [ 'kivi.Order.delete_order' ],
1570         confirm  => $::locale->text('Do you really want to delete this object?'),
1571         disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1572         only_if  => $deletion_allowed,
1573       ],
1574     );
1575   }
1576 }
1577
1578 sub generate_pdf {
1579   my ($order, $pdf_ref, $params) = @_;
1580
1581   my @errors = ();
1582
1583   my $print_form = Form->new('');
1584   $print_form->{type}        = $order->type;
1585   $print_form->{formname}    = $params->{formname} || $order->type;
1586   $print_form->{format}      = $params->{format}   || 'pdf';
1587   $print_form->{media}       = $params->{media}    || 'file';
1588   $print_form->{groupitems}  = $params->{groupitems};
1589   $print_form->{media}       = 'file'                             if $print_form->{media} eq 'screen';
1590
1591   $order->language($params->{language});
1592   $order->flatten_to_form($print_form, format_amounts => 1);
1593
1594   my $template_ext;
1595   my $template_type;
1596   if ($print_form->{format} =~ /(opendocument|oasis)/i) {
1597     $template_ext  = 'odt';
1598     $template_type = 'OpenDocument';
1599   }
1600
1601   # search for the template
1602   my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1603     name        => $print_form->{formname},
1604     extension   => $template_ext,
1605     email       => $print_form->{media} eq 'email',
1606     language    => $params->{language},
1607     printer_id  => $print_form->{printer_id},  # todo
1608   );
1609
1610   if (!defined $template_file) {
1611     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);
1612   }
1613
1614   return @errors if scalar @errors;
1615
1616   $print_form->throw_on_error(sub {
1617     eval {
1618       $print_form->prepare_for_printing;
1619
1620       $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1621         format        => $print_form->{format},
1622         template_type => $template_type,
1623         template      => $template_file,
1624         variables     => $print_form,
1625         variable_content_types => {
1626           longdescription => 'html',
1627           partnotes       => 'html',
1628           notes           => 'html',
1629         },
1630       );
1631       1;
1632     } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->getMessage : $EVAL_ERROR;
1633   });
1634
1635   return @errors;
1636 }
1637
1638 sub get_files_for_email_dialog {
1639   my ($self) = @_;
1640
1641   my %files = map { ($_ => []) } qw(versions files vc_files part_files);
1642
1643   return %files if !$::instance_conf->get_doc_storage;
1644
1645   if ($self->order->id) {
1646     $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id,              object_type => $self->order->type, file_type => 'document') ];
1647     $files{files}    = [ SL::File->get_all(         object_id => $self->order->id,              object_type => $self->order->type, file_type => 'attachment') ];
1648     $files{vc_files} = [ SL::File->get_all(         object_id => $self->order->{$self->cv}->id, object_type => $self->cv,          file_type => 'attachment') ];
1649   }
1650
1651   my @parts =
1652     uniq_by { $_->{id} }
1653     map {
1654       +{ id         => $_->part->id,
1655          partnumber => $_->part->partnumber }
1656     } @{$self->order->items_sorted};
1657
1658   foreach my $part (@parts) {
1659     my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
1660     push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
1661   }
1662
1663   foreach my $key (keys %files) {
1664     $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
1665   }
1666
1667   return %files;
1668 }
1669
1670 sub make_periodic_invoices_config_from_yaml {
1671   my ($yaml_config) = @_;
1672
1673   return if !$yaml_config;
1674   my $attr = YAML::Load($yaml_config);
1675   return if 'HASH' ne ref $attr;
1676   return SL::DB::PeriodicInvoicesConfig->new(%$attr);
1677 }
1678
1679
1680 sub get_periodic_invoices_status {
1681   my ($self, $config) = @_;
1682
1683   return                      if $self->type ne sales_order_type();
1684   return t8('not configured') if !$config;
1685
1686   my $active = ('HASH' eq ref $config)                           ? $config->{active}
1687              : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
1688              :                                                     die "Cannot get status of periodic invoices config";
1689
1690   return $active ? t8('active') : t8('inactive');
1691 }
1692
1693 sub get_title_for {
1694   my ($self, $action) = @_;
1695
1696   return '' if none { lc($action)} qw(add edit);
1697
1698   # for locales:
1699   # $::locale->text("Add Sales Order");
1700   # $::locale->text("Add Purchase Order");
1701   # $::locale->text("Add Quotation");
1702   # $::locale->text("Add Request for Quotation");
1703   # $::locale->text("Edit Sales Order");
1704   # $::locale->text("Edit Purchase Order");
1705   # $::locale->text("Edit Quotation");
1706   # $::locale->text("Edit Request for Quotation");
1707
1708   $action = ucfirst(lc($action));
1709   return $self->type eq sales_order_type()       ? $::locale->text("$action Sales Order")
1710        : $self->type eq purchase_order_type()    ? $::locale->text("$action Purchase Order")
1711        : $self->type eq sales_quotation_type()   ? $::locale->text("$action Quotation")
1712        : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
1713        : '';
1714 }
1715
1716 sub sales_order_type {
1717   'sales_order';
1718 }
1719
1720 sub purchase_order_type {
1721   'purchase_order';
1722 }
1723
1724 sub sales_quotation_type {
1725   'sales_quotation';
1726 }
1727
1728 sub request_quotation_type {
1729   'request_quotation';
1730 }
1731
1732 sub nr_key {
1733   return $_[0]->type eq sales_order_type()       ? 'ordnumber'
1734        : $_[0]->type eq purchase_order_type()    ? 'ordnumber'
1735        : $_[0]->type eq sales_quotation_type()   ? 'quonumber'
1736        : $_[0]->type eq request_quotation_type() ? 'quonumber'
1737        : '';
1738 }
1739
1740 1;
1741
1742 __END__
1743
1744 =encoding utf-8
1745
1746 =head1 NAME
1747
1748 SL::Controller::Order - controller for orders
1749
1750 =head1 SYNOPSIS
1751
1752 This is a new form to enter orders, completely rewritten with the use
1753 of controller and java script techniques.
1754
1755 The aim is to provide the user a better expirience and a faster flow
1756 of work. Also the code should be more readable, more reliable and
1757 better to maintain.
1758
1759 =head2 Key Features
1760
1761 =over 4
1762
1763 =item *
1764
1765 One input row, so that input happens every time at the same place.
1766
1767 =item *
1768
1769 Use of pickers where possible.
1770
1771 =item *
1772
1773 Possibility to enter more than one item at once.
1774
1775 =item *
1776
1777 Save order only on "save" (and "save and delivery order"-workflow). No
1778 hidden save on "print" or "email".
1779
1780 =item *
1781
1782 Item list in a scrollable area, so that the workflow buttons stay at
1783 the bottom.
1784
1785 =item *
1786
1787 Reordering item rows with drag and drop is possible. Sorting item rows is
1788 possible (by partnumber, description, qty, sellprice and discount for now).
1789
1790 =item *
1791
1792 No C<update> is necessary. All entries and calculations are managed
1793 with ajax-calls and the page does only reload on C<save>.
1794
1795 =item *
1796
1797 User can see changes immediately, because of the use of java script
1798 and ajax.
1799
1800 =back
1801
1802 =head1 CODE
1803
1804 =head2 Layout
1805
1806 =over 4
1807
1808 =item * C<SL/Controller/Order.pm>
1809
1810 the controller
1811
1812 =item * C<template/webpages/order/form.html>
1813
1814 main form
1815
1816 =item * C<template/webpages/order/tabs/basic_data.html>
1817
1818 Main tab for basic_data.
1819
1820 This is the only tab here for now. "linked records" and "webdav" tabs are
1821 reused from generic code.
1822
1823 =over 4
1824
1825 =item * C<template/webpages/order/tabs/_business_info_row.html>
1826
1827 For displaying information on business type
1828
1829 =item * C<template/webpages/order/tabs/_item_input.html>
1830
1831 The input line for items
1832
1833 =item * C<template/webpages/order/tabs/_row.html>
1834
1835 One row for already entered items
1836
1837 =item * C<template/webpages/order/tabs/_tax_row.html>
1838
1839 Displaying tax information
1840
1841 =item * C<template/webpages/order/tabs/_multi_items_dialog.html>
1842
1843 Dialog for entering more than one item at once
1844
1845 =item * C<template/webpages/order/tabs/_multi_items_result.html>
1846
1847 Results for the filter in the multi items dialog
1848
1849 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
1850
1851 Dialog for selecting price and discount sources
1852
1853 =back
1854
1855 =item * C<js/kivi.Order.js>
1856
1857 java script functions
1858
1859 =back
1860
1861 =head1 TODO
1862
1863 =over 4
1864
1865 =item * testing
1866
1867 =item * currency
1868
1869 =item * credit limit
1870
1871 =item * more workflows (save as new, quotation, purchase order)
1872
1873 =item * price sources: little symbols showing better price / better discount
1874
1875 =item * select units in input row?
1876
1877 =item * custom shipto address
1878
1879 =item * check for direct delivery (workflow sales order -> purchase order)
1880
1881 =item * language / part translations
1882
1883 =item * access rights
1884
1885 =item * display weights
1886
1887 =item * history
1888
1889 =item * mtime check
1890
1891 =item * optional client/user behaviour
1892
1893 (transactions has to be set - department has to be set -
1894  force project if enabled in client config - transport cost reminder)
1895
1896 =back
1897
1898 =head1 KNOWN BUGS AND CAVEATS
1899
1900 =over 4
1901
1902 =item *
1903
1904 Customer discount is not displayed as a valid discount in price source popup
1905 (this might be a bug in price sources)
1906
1907 (I cannot reproduce this (Bernd))
1908
1909 =item *
1910
1911 No indication that <shift>-up/down expands/collapses second row.
1912
1913 =item *
1914
1915 Inline creation of parts is not currently supported
1916
1917 =item *
1918
1919 Table header is not sticky in the scrolling area.
1920
1921 =item *
1922
1923 Sorting does not include C<position>, neither does reordering.
1924
1925 This behavior was implemented intentionally. But we can discuss, which behavior
1926 should be implemented.
1927
1928 =item *
1929
1930 C<show_multi_items_dialog> does not use the currently inserted string for
1931 filtering.
1932
1933 =item *
1934
1935 The language selected in print or email dialog is not saved when the order is saved.
1936
1937 =back
1938
1939 =head1 To discuss / Nice to have
1940
1941 =over 4
1942
1943 =item *
1944
1945 How to expand/collapse second row. Now it can be done clicking the icon or
1946 <shift>-up/down.
1947
1948 =item *
1949
1950 Possibility to change longdescription in input row?
1951
1952 =item *
1953
1954 Possibility to select PriceSources in input row?
1955
1956 =item *
1957
1958 This controller uses a (changed) copy of the template for the PriceSource
1959 dialog. Maybe there could be used one code source.
1960
1961 =item *
1962
1963 Rounding-differences between this controller (PriceTaxCalculator) and the old
1964 form. This is not only a problem here, but also in all parts using the PTC.
1965 There exists a ticket and a patch. This patch should be testet.
1966
1967 =item *
1968
1969 An indicator, if the actual inputs are saved (like in an
1970 editor or on text processing application).
1971
1972 =item *
1973
1974 A warning when leaving the page without saveing unchanged inputs.
1975
1976 =item *
1977
1978 Workflows for delivery order and invoice are in the menu "Save", because the
1979 order is saved before opening the new document form. Nevertheless perhaps these
1980 workflow buttons should be put under "Workflows".
1981
1982
1983 =back
1984
1985 =head1 AUTHOR
1986
1987 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>
1988
1989 =cut