1 package SL::Controller::Order;
4 use parent qw(SL::Controller::Base);
6 use SL::Helper::Flash qw(flash_later);
7 use SL::Presenter::Tag qw(select_tag hidden_tag);
8 use SL::Locale::String qw(t8);
9 use SL::SessionFile::Random;
21 use SL::Helper::CreatePDF qw(:all);
22 use SL::Helper::PrintOptions;
24 use SL::Controller::Helper::GetModels;
26 use List::Util qw(first);
27 use List::UtilsBy qw(sort_by uniq_by);
28 use List::MoreUtils qw(any none pairwise first_index);
29 use English qw(-no_match_vars);
33 use Rose::Object::MakeMethods::Generic
35 scalar => [ qw(item_ids_to_delete) ],
36 'scalar --get_set_init' => [ qw(order valid_types type cv p multi_items_models all_price_factors) ],
41 __PACKAGE__->run_before('_check_auth');
43 __PACKAGE__->run_before('_recalc',
44 only => [ qw(save save_and_delivery_order save_and_invoice print create_pdf send_email) ]);
46 __PACKAGE__->run_before('_get_unalterable_data',
47 only => [ qw(save save_and_delivery_order save_and_invoice print create_pdf send_email) ]);
57 $self->order->transdate(DateTime->now_local());
58 $self->order->reqdate(DateTime->today_local->next_workday) if !$self->order->reqdate;
63 title => $self->type eq _sales_order_type() ? $::locale->text('Add Sales Order')
64 : $self->type eq _purchase_order_type() ? $::locale->text('Add Purchase Order')
65 : $self->type eq _sales_quotation_type() ? $::locale->text('Add Quotation')
66 : $self->type eq _request_quotation_type() ? $::locale->text('Add Request for Quotation')
68 %{$self->{template_args}}
72 # edit an existing order
81 title => $self->type eq _sales_order_type() ? $::locale->text('Edit Sales Order')
82 : $self->type eq _purchase_order_type() ? $::locale->text('Edit Purchase Order')
83 : $self->type eq _sales_quotation_type() ? $::locale->text('Edit Quotation')
84 : $self->type eq _request_quotation_type() ? $::locale->text('Edit Request for Quotation')
86 %{$self->{template_args}}
94 my $errors = $self->_delete();
96 if (scalar @{ $errors }) {
97 $self->js->flash('error', $_) foreach @{ $errors };
98 return $self->js->render();
101 my $text = $self->type eq _sales_order_type() ? $::locale->text('The order has been deleted')
102 : $self->type eq _purchase_order_type() ? $::locale->text('The order has been deleted')
103 : $self->type eq _sales_quotation_type() ? $::locale->text('The quotation has been deleted')
104 : $self->type eq _request_quotation_type() ? $::locale->text('The rfq has been deleted')
106 flash_later('info', $text);
108 my @redirect_params = (
113 $self->redirect_to(@redirect_params);
120 my $errors = $self->_save();
122 if (scalar @{ $errors }) {
123 $self->js->flash('error', $_) foreach @{ $errors };
124 return $self->js->render();
127 my $text = $self->type eq _sales_order_type() ? $::locale->text('The order has been saved')
128 : $self->type eq _purchase_order_type() ? $::locale->text('The order has been saved')
129 : $self->type eq _sales_quotation_type() ? $::locale->text('The quotation has been saved')
130 : $self->type eq _request_quotation_type() ? $::locale->text('The rfq has been saved')
132 flash_later('info', $text);
134 my @redirect_params = (
137 id => $self->order->id,
140 $self->redirect_to(@redirect_params);
145 # This is called if "print" is pressed in the print dialog.
146 # If PDF creation was requested and succeeded, the pdf is stored in a session
147 # file and the filename is stored as session value with an unique key. A
148 # javascript function with this key is then called. This function calls the
149 # download action below (action_download_pdf), which offers the file for
154 my $format = $::form->{print_options}->{format};
155 my $media = $::form->{print_options}->{media};
156 my $formname = $::form->{print_options}->{formname};
157 my $copies = $::form->{print_options}->{copies};
158 my $groupitems = $::form->{print_options}->{groupitems};
161 if (none { $format eq $_ } qw(pdf)) {
162 return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
165 # only screen or printer by now
166 if (none { $media eq $_ } qw(screen printer)) {
167 return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
171 $language = SL::DB::Language->new(id => $::form->{print_options}->{language_id})->load if $::form->{print_options}->{language_id};
173 # create a form for generate_attachment_filename
174 my $form = Form->new;
175 $form->{ordnumber} = $self->order->ordnumber;
176 $form->{type} = $self->type;
177 $form->{format} = $format;
178 $form->{formname} = $formname;
179 $form->{language} = '_' . $language->template_code if $language;
180 my $pdf_filename = $form->generate_attachment_filename();
183 my @errors = _create_pdf($self->order, \$pdf, { format => $format,
184 formname => $formname,
185 language => $language,
186 groupitems => $groupitems });
187 if (scalar @errors) {
188 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
191 if ($media eq 'screen') {
193 my $sfile = SL::SessionFile::Random->new(mode => "w");
194 $sfile->fh->print($pdf);
197 my $key = join('_', Time::HiRes::gettimeofday(), int rand 1000000000000);
198 $::auth->set_session_value("Order::create_pdf-${key}" => $sfile->file_name);
201 ->run('kivi.Order.download_pdf', $pdf_filename, $key)
202 ->flash('info', t8('The PDF has been created'));
204 } elsif ($media eq 'printer') {
206 my $printer_id = $::form->{print_options}->{printer_id};
207 SL::DB::Printer->new(id => $printer_id)->load->print_document(
212 $self->js->flash('info', t8('The PDF has been printed'));
215 # copy file to webdav folder
216 if ($self->order->ordnumber && $::instance_conf->get_webdav_documents) {
217 my $webdav = SL::Webdav->new(
219 number => $self->order->ordnumber,
221 my $webdav_file = SL::Webdav::File->new(
223 filename => $pdf_filename,
226 $webdav_file->store(data => \$pdf);
229 $self->js->flash('error', t8('Storing PDF to webdav folder failed: #1', $@));
232 if ($self->order->ordnumber && $::instance_conf->get_doc_storage) {
234 SL::File->save(object_id => $self->order->id,
235 object_type => $self->type,
236 mime_type => 'application/pdf',
238 file_type => 'document',
239 file_name => $pdf_filename,
240 file_contents => $pdf);
243 $self->js->flash('error', t8('Storing PDF in storage backend failed: #1', $@));
249 # offer pdf for download
251 # It needs to get the key for the session value to get the pdf file.
252 sub action_download_pdf {
255 my $key = $::form->{key};
256 my $tmp_filename = $::auth->get_session_value("Order::create_pdf-${key}");
257 return $self->send_file(
259 type => 'application/pdf',
260 name => $::form->{pdf_filename},
264 # open the email dialog
265 sub action_show_email_dialog {
268 my $cv_method = $self->cv;
270 if (!$self->order->$cv_method) {
271 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'))
276 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
277 $email_form->{to} ||= $self->order->$cv_method->email;
278 $email_form->{cc} = $self->order->$cv_method->cc;
279 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
280 # Todo: get addresses from shipto, if any
282 my $form = Form->new;
283 $form->{ordnumber} = $self->order->ordnumber;
284 $form->{formname} = $self->type;
285 $form->{type} = $self->type;
286 $form->{language} = 'de';
287 $form->{format} = 'pdf';
289 $email_form->{subject} = $form->generate_email_subject();
290 $email_form->{attachment_filename} = $form->generate_attachment_filename();
291 $email_form->{message} = $form->generate_email_body();
292 $email_form->{js_send_function} = 'kivi.Order.send_email()';
294 my %files = $self->_get_files_for_email_dialog();
295 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
296 email_form => $email_form,
297 show_bcc => $::auth->assert('email_bcc', 'may fail'),
299 is_customer => $self->cv eq 'customer',
303 ->run('kivi.Order.show_email_dialog', $dialog_html)
310 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
311 sub action_send_email {
314 my $email_form = delete $::form->{email_form};
315 my %field_names = (to => 'email');
317 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
319 # for Form::cleanup which may be called in Form::send_email
320 $::form->{cwd} = getcwd();
321 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
323 $::form->{media} = 'email';
325 if (($::form->{attachment_policy} // '') eq 'normal') {
327 $language = SL::DB::Language->new(id => $::form->{print_options}->{language_id})->load if $::form->{print_options}->{language_id};
330 my @errors = _create_pdf($self->order, \$pdf, {media => $::form->{media},
331 format => $::form->{print_options}->{format},
332 formname => $::form->{print_options}->{formname},
333 language => $language,
334 groupitems => $::form->{print_options}->{groupitems}});
335 if (scalar @errors) {
336 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
339 my $sfile = SL::SessionFile::Random->new(mode => "w");
340 $sfile->fh->print($pdf);
343 $::form->{tmpfile} = $sfile->file_name;
344 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
347 $::form->send_email(\%::myconfig, 'pdf');
350 my $intnotes = $self->order->intnotes;
351 $intnotes .= "\n\n" if $self->order->intnotes;
352 $intnotes .= t8('[email]') . "\n";
353 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
354 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
355 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
356 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
357 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
358 $intnotes .= t8('Message') . ": " . $::form->{message};
361 ->val('#order_intnotes', $intnotes)
362 ->run('kivi.Order.close_email_dialog')
363 ->flash('info', t8('The email has been sent.'))
367 # open the periodic invoices config dialog
369 # If there are values in the form (i.e. dialog was opened before),
370 # then use this values. Create new ones, else.
371 sub action_show_periodic_invoices_config_dialog {
374 my $config = _make_periodic_invoices_config_from_yaml(delete $::form->{config});
375 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
376 $config ||= SL::DB::PeriodicInvoicesConfig->new(periodicity => 'm',
377 order_value_periodicity => 'p', # = same as periodicity
378 start_date_as_date => $::form->{transdate} || $::form->current_date,
379 extend_automatically_by => 12,
381 email_subject => GenericTranslations->get(
382 language_id => $::form->{language_id},
383 translation_type =>"preset_text_periodic_invoices_email_subject"),
384 email_body => GenericTranslations->get(
385 language_id => $::form->{language_id},
386 translation_type =>"preset_text_periodic_invoices_email_body"),
388 $config->periodicity('m') if none { $_ eq $config->periodicity } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
389 $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
391 $::form->get_lists(printers => "ALL_PRINTERS",
392 charts => { key => 'ALL_CHARTS',
393 transdate => 'current_date' });
395 $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
397 if ($::form->{customer_id}) {
398 $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
401 $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
403 popup_js_close_function => 'kivi.Order.close_periodic_invoices_config_dialog()',
404 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
409 # assign the values of the periodic invoices config dialog
410 # as yaml in the hidden tag and set the status.
411 sub action_assign_periodic_invoices_config {
414 $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
416 my $config = { active => $::form->{active} ? 1 : 0,
417 terminated => $::form->{terminated} ? 1 : 0,
418 direct_debit => $::form->{direct_debit} ? 1 : 0,
419 periodicity => (any { $_ eq $::form->{periodicity} } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES) ? $::form->{periodicity} : 'm',
420 order_value_periodicity => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
421 start_date_as_date => $::form->{start_date_as_date},
422 end_date_as_date => $::form->{end_date_as_date},
423 first_billing_date_as_date => $::form->{first_billing_date_as_date},
424 print => $::form->{print} ? 1 : 0,
425 printer_id => $::form->{print} ? $::form->{printer_id} * 1 : undef,
426 copies => $::form->{copies} * 1 ? $::form->{copies} : 1,
427 extend_automatically_by => $::form->{extend_automatically_by} * 1 || undef,
428 ar_chart_id => $::form->{ar_chart_id} * 1,
429 send_email => $::form->{send_email} ? 1 : 0,
430 email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
431 email_recipient_address => $::form->{email_recipient_address},
432 email_sender => $::form->{email_sender},
433 email_subject => $::form->{email_subject},
434 email_body => $::form->{email_body},
437 my $periodic_invoices_config = YAML::Dump($config);
439 my $status = $self->_get_periodic_invoices_status($config);
442 ->remove('#order_periodic_invoices_config')
443 ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
444 ->run('kivi.Order.close_periodic_invoices_config_dialog')
445 ->html('#periodic_invoices_status', $status)
446 ->flash('info', t8('The periodic invoices config has been assigned.'))
450 sub action_get_has_active_periodic_invoices {
453 my $config = _make_periodic_invoices_config_from_yaml(delete $::form->{config});
454 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
456 my $has_active_periodic_invoices =
457 $self->type eq _sales_order_type()
460 && (!$config->end_date || ($config->end_date > DateTime->today_local))
461 && $config->get_previous_billed_period_start_date;
463 $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
466 # save the order and redirect to the frontend subroutine for a new
468 sub action_save_and_delivery_order {
471 my $errors = $self->_save();
473 if (scalar @{ $errors }) {
474 $self->js->flash('error', $_) foreach @{ $errors };
475 return $self->js->render();
478 my $text = $self->type eq _sales_order_type() ? $::locale->text('The order has been saved')
479 : $self->type eq _purchase_order_type() ? $::locale->text('The order has been saved')
480 : $self->type eq _sales_quotation_type() ? $::locale->text('The quotation has been saved')
481 : $self->type eq _request_quotation_type() ? $::locale->text('The rfq has been saved')
483 flash_later('info', $text);
485 my @redirect_params = (
486 controller => 'oe.pl',
487 action => 'oe_delivery_order_from_order',
488 id => $self->order->id,
491 $self->redirect_to(@redirect_params);
494 # save the order and redirect to the frontend subroutine for a new
496 sub action_save_and_invoice {
499 my $errors = $self->_save();
501 if (scalar @{ $errors }) {
502 $self->js->flash('error', $_) foreach @{ $errors };
503 return $self->js->render();
506 my $text = $self->type eq _sales_order_type() ? $::locale->text('The order has been saved')
507 : $self->type eq _purchase_order_type() ? $::locale->text('The order has been saved')
508 : $self->type eq _sales_quotation_type() ? $::locale->text('The quotation has been saved')
509 : $self->type eq _request_quotation_type() ? $::locale->text('The rfq has been saved')
511 flash_later('info', $text);
513 my @redirect_params = (
514 controller => 'oe.pl',
515 action => 'oe_invoice_from_order',
516 id => $self->order->id,
519 $self->redirect_to(@redirect_params);
522 # set form elements in respect to a changed customer or vendor
524 # This action is called on an change of the customer/vendor picker.
525 sub action_customer_vendor_changed {
528 my $cv_method = $self->cv;
530 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
531 $self->js->show('#cp_row');
533 $self->js->hide('#cp_row');
536 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
537 $self->js->show('#shipto_row');
539 $self->js->hide('#shipto_row');
542 $self->order->taxzone_id($self->order->$cv_method->taxzone_id);
544 if ($self->order->is_sales) {
545 $self->order->taxincluded(defined($self->order->$cv_method->taxincluded_checked)
546 ? $self->order->$cv_method->taxincluded_checked
547 : $::myconfig{taxincluded_checked});
548 $self->js->val('#order_salesman_id', $self->order->$cv_method->salesman_id);
551 $self->order->payment_id($self->order->$cv_method->payment_id);
552 $self->order->delivery_term_id($self->order->$cv_method->delivery_term_id);
557 ->replaceWith('#order_cp_id', $self->build_contact_select)
558 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
559 ->val( '#order_taxzone_id', $self->order->taxzone_id)
560 ->val( '#order_taxincluded', $self->order->taxincluded)
561 ->val( '#order_payment_id', $self->order->payment_id)
562 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
563 ->val( '#order_intnotes', $self->order->$cv_method->notes)
564 ->focus( '#order_' . $self->cv . '_id');
566 $self->_js_redisplay_amounts_and_taxes;
570 # called if a unit in an existing item row is changed
571 sub action_unit_changed {
574 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
575 my $item = $self->order->items_sorted->[$idx];
577 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
578 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
583 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
584 $self->_js_redisplay_line_values;
585 $self->_js_redisplay_amounts_and_taxes;
589 # add an item row for a new item entered in the input row
590 sub action_add_item {
593 my $form_attr = $::form->{add_item};
595 return unless $form_attr->{parts_id};
597 my $item = _new_item($self->order, $form_attr);
599 $self->order->add_items($item);
603 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
604 my $row_as_html = $self->p->render('order/tabs/_row',
608 ALL_PRICE_FACTORS => $self->all_price_factors
612 ->append('#row_table_id', $row_as_html);
614 if ( $item->part->is_assortment ) {
615 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
616 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
617 my $attr = { parts_id => $assortment_item->parts_id,
618 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
619 unit => $assortment_item->unit,
620 description => $assortment_item->part->description,
622 my $item = _new_item($self->order, $attr);
624 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
625 $item->discount(1) unless $assortment_item->charge;
627 $self->order->add_items( $item );
629 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
630 my $row_as_html = $self->p->render('order/tabs/_row',
634 ALL_PRICE_FACTORS => $self->all_price_factors
637 ->append('#row_table_id', $row_as_html);
642 ->val('.add_item_input', '')
643 ->run('kivi.Order.init_row_handlers')
644 ->run('kivi.Order.row_table_scroll_down')
645 ->run('kivi.Order.renumber_positions')
646 ->focus('#add_item_parts_id_name');
648 $self->_js_redisplay_amounts_and_taxes;
652 # open the dialog for entering multiple items at once
653 sub action_show_multi_items_dialog {
654 require SL::DB::PartsGroup;
655 $_[0]->render('order/tabs/_multi_items_dialog', { layout => 0 },
656 all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
659 # update the filter results in the multi item dialog
660 sub action_multi_items_update_result {
663 $::form->{multi_items}->{filter}->{obsolete} = 0;
665 my $count = $_[0]->multi_items_models->count;
668 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
669 $_[0]->render($text, { layout => 0 });
670 } elsif ($count > $max_count) {
671 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
672 $_[0]->render($text, { layout => 0 });
674 my $multi_items = $_[0]->multi_items_models->get;
675 $_[0]->render('order/tabs/_multi_items_result', { layout => 0 },
676 multi_items => $multi_items);
680 # add item rows for multiple items at once
681 sub action_add_multi_items {
684 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_multi_items} };
685 return $self->js->render() unless scalar @form_attr;
688 foreach my $attr (@form_attr) {
689 my $item = _new_item($self->order, $attr);
691 if ( $item->part->is_assortment ) {
692 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
693 my $attr = { parts_id => $assortment_item->parts_id,
694 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
695 unit => $assortment_item->unit,
696 description => $assortment_item->part->description,
698 my $item = _new_item($self->order, $attr);
700 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
701 $item->discount(1) unless $assortment_item->charge;
706 $self->order->add_items(@items);
710 foreach my $item (@items) {
711 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
712 my $row_as_html = $self->p->render('order/tabs/_row',
716 ALL_PRICE_FACTORS => $self->all_price_factors
719 $self->js->append('#row_table_id', $row_as_html);
723 ->run('kivi.Order.close_multi_items_dialog')
724 ->run('kivi.Order.init_row_handlers')
725 ->run('kivi.Order.row_table_scroll_down')
726 ->run('kivi.Order.renumber_positions')
727 ->focus('#add_item_parts_id_name');
729 $self->_js_redisplay_amounts_and_taxes;
733 # recalculate all linetotals, amounts and taxes and redisplay them
734 sub action_recalc_amounts_and_taxes {
739 $self->_js_redisplay_line_values;
740 $self->_js_redisplay_amounts_and_taxes;
744 # redisplay item rows if they are sorted by an attribute
745 sub action_reorder_items {
749 partnumber => sub { $_[0]->part->partnumber },
750 description => sub { $_[0]->description },
751 qty => sub { $_[0]->qty },
752 sellprice => sub { $_[0]->sellprice },
753 discount => sub { $_[0]->discount },
756 my $method = $sort_keys{$::form->{order_by}};
757 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
758 if ($::form->{sort_dir}) {
759 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
761 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
764 ->run('kivi.Order.redisplay_items', \@to_sort)
768 # show the popup to choose a price/discount source
769 sub action_price_popup {
772 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
773 my $item = $self->order->items_sorted->[$idx];
775 $self->render_price_dialog($item);
778 # get the longdescription for an item if the dialog to enter/change the
779 # longdescription was opened and the longdescription is empty
781 # If this item is new, get the longdescription from Part.
782 # Otherwise get it from OrderItem.
783 sub action_get_item_longdescription {
786 if ($::form->{item_id}) {
787 $longdescription = SL::DB::OrderItem->new(id => $::form->{item_id})->load->longdescription;
788 } elsif ($::form->{parts_id}) {
789 $longdescription = SL::DB::Part->new(id => $::form->{parts_id})->load->notes;
791 $_[0]->render(\ $longdescription, { type => 'text' });
794 # load the second row for one or more items
796 # This action gets the html code for all items second rows by rendering a template for
797 # the second row and sets the html code via client js.
798 sub action_load_second_rows {
801 $self->_recalc() if $self->order->is_sales; # for margin calculation
803 foreach my $item_id (@{ $::form->{item_ids} }) {
804 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
805 my $item = $self->order->items_sorted->[$idx];
807 $self->_js_load_second_row($item, $item_id, 0);
810 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
815 sub _js_load_second_row {
816 my ($self, $item, $item_id, $do_parse) = @_;
819 # Parse values from form (they are formated while rendering (template)).
820 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
821 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
822 foreach my $var (@{ $item->cvars_by_config }) {
823 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
825 $item->parse_custom_variable_values;
828 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
831 ->html('.row_entry:has(#item_' . $item_id . ') [name = "second_row"]', $row_as_html)
832 ->data('.row_entry:has(#item_' . $item_id . ') [name = "second_row"]', 'loaded', 1);
835 sub _js_redisplay_line_values {
838 my $is_sales = $self->order->is_sales;
840 # sales orders with margins
845 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
846 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
847 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
848 ]} @{ $self->order->items_sorted };
852 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
853 ]} @{ $self->order->items_sorted };
857 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
860 sub _js_redisplay_amounts_and_taxes {
863 if (scalar @{ $self->{taxes} }) {
864 $self->js->show('#taxincluded_row_id');
866 $self->js->hide('#taxincluded_row_id');
869 if ($self->order->taxincluded) {
870 $self->js->hide('#subtotal_row_id');
872 $self->js->show('#subtotal_row_id');
876 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
877 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
879 ->insertBefore($self->build_tax_rows, '#amount_row_id');
886 sub init_valid_types {
887 [ _sales_order_type(), _purchase_order_type(), _sales_quotation_type(), _request_quotation_type() ];
893 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
894 die "Not a valid type for order";
897 $self->type($::form->{type});
903 my $cv = (any { $self->type eq $_ } (_sales_order_type(), _sales_quotation_type())) ? 'customer'
904 : (any { $self->type eq $_ } (_purchase_order_type(), _request_quotation_type())) ? 'vendor'
905 : die "Not a valid type for order";
918 # model used to filter/display the parts in the multi-items dialog
919 sub init_multi_items_models {
920 SL::Controller::Helper::GetModels->new(
923 with_objects => [ qw(unit_obj) ],
924 disable_plugin => 'paginated',
925 source => $::form->{multi_items},
931 partnumber => t8('Partnumber'),
932 description => t8('Description')}
936 sub init_all_price_factors {
937 SL::DB::Manager::PriceFactor->get_all;
943 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
945 my $right = $right_for->{ $self->type };
946 $right ||= 'DOES_NOT_EXIST';
948 $::auth->assert($right);
951 # build the selection box for contacts
953 # Needed, if customer/vendor changed.
954 sub build_contact_select {
957 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
958 value_key => 'cp_id',
959 title_key => 'full_name_dep',
960 default => $self->order->cp_id,
962 style => 'width: 300px',
966 # build the selection box for shiptos
968 # Needed, if customer/vendor changed.
969 sub build_shipto_select {
972 select_tag('order.shipto_id', [ $self->order->{$self->cv}->shipto ],
973 value_key => 'shipto_id',
974 title_key => 'displayable_id',
975 default => $self->order->shipto_id,
977 style => 'width: 300px',
981 # build the rows for displaying taxes
983 # Called if amounts where recalculated and redisplayed.
988 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
989 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
991 return $rows_as_html;
995 sub render_price_dialog {
996 my ($self, $record_item) = @_;
998 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1002 'kivi.io.price_chooser_dialog',
1003 t8('Available Prices'),
1004 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1009 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1010 # $self->js->show('#dialog_flash_error');
1019 return if !$::form->{id};
1021 $self->order(SL::DB::Manager::Order->find_by(id => $::form->{id}));
1024 # load or create a new order object
1026 # And assign changes from the form to this object.
1027 # If the order is loaded from db, check if items are deleted in the form,
1028 # remove them form the object and collect them for removing from db on saving.
1029 # Then create/update items from form (via _make_item) and add them.
1033 # add_items adds items to an order with no items for saving, but they cannot
1034 # be retrieved via items until the order is saved. Adding empty items to new
1035 # order here solves this problem.
1037 $order = SL::DB::Manager::Order->find_by(id => $::form->{id}) if $::form->{id};
1038 $order ||= SL::DB::Order->new(orderitems => [],
1039 quotation => (any { $self->type eq $_ } (_sales_quotation_type(), _request_quotation_type())));
1041 my $form_orderitems = delete $::form->{order}->{orderitems};
1042 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1044 $order->assign_attributes(%{$::form->{order}});
1046 my $periodic_invoices_config = _make_periodic_invoices_config_from_yaml($form_periodic_invoices_config);
1047 $order->periodic_invoices_config($periodic_invoices_config) if $periodic_invoices_config;
1049 # remove deleted items
1050 $self->item_ids_to_delete([]);
1051 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1052 my $item = $order->orderitems->[$idx];
1053 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1054 splice @{$order->orderitems}, $idx, 1;
1055 push @{$self->item_ids_to_delete}, $item->id;
1061 foreach my $form_attr (@{$form_orderitems}) {
1062 my $item = _make_item($order, $form_attr);
1063 $item->position($pos);
1067 $order->add_items(grep {!$_->id} @items);
1072 # create or update items from form
1074 # Make item objects from form values. For items already existing read from db.
1075 # Create a new item else. And assign attributes.
1077 my ($record, $attr) = @_;
1080 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1082 my $is_new = !$item;
1084 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1085 # they cannot be retrieved via custom_variables until the order/orderitem is
1086 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1087 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1089 $item->assign_attributes(%$attr);
1090 $item->longdescription($item->part->notes) if $is_new && !defined $attr->{longdescription};
1091 $item->project_id($record->globalproject_id) if $is_new && !defined $attr->{project_id};
1092 $item->lastcost($item->part->lastcost) if $is_new && !defined $attr->{lastcost_as_number};
1099 # This is used to add one item
1101 my ($record, $attr) = @_;
1103 my $item = SL::DB::OrderItem->new;
1104 $item->assign_attributes(%$attr);
1106 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1107 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1109 $item->unit($part->unit) if !$item->unit;
1112 if ( $part->is_assortment ) {
1113 # add assortment items with price 0, as the components carry the price
1114 $price_src = $price_source->price_from_source("");
1115 $price_src->price(0);
1116 } elsif ($item->sellprice) {
1117 $price_src = $price_source->price_from_source("");
1118 $price_src->price($item->sellprice);
1120 $price_src = $price_source->best_price
1121 ? $price_source->best_price
1122 : $price_source->price_from_source("");
1123 $price_src->price(0) if !$price_source->best_price;
1127 if ($item->discount) {
1128 $discount_src = $price_source->discount_from_source("");
1129 $discount_src->discount($item->discount);
1131 $discount_src = $price_source->best_discount
1132 ? $price_source->best_discount
1133 : $price_source->discount_from_source("");
1134 $discount_src->discount(0) if !$price_source->best_discount;
1138 $new_attr{part} = $part;
1139 $new_attr{description} = $part->description if ! $item->description;
1140 $new_attr{qty} = 1.0 if ! $item->qty;
1141 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1142 $new_attr{sellprice} = $price_src->price;
1143 $new_attr{discount} = $discount_src->discount;
1144 $new_attr{active_price_source} = $price_src;
1145 $new_attr{active_discount_source} = $discount_src;
1146 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1147 $new_attr{project_id} = $record->globalproject_id;
1148 $new_attr{lastcost} = $part->lastcost;
1150 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1151 # they cannot be retrieved via custom_variables until the order/orderitem is
1152 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1153 $new_attr{custom_variables} = [];
1155 $item->assign_attributes(%new_attr);
1160 # recalculate prices and taxes
1162 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1166 # bb: todo: currency later
1167 $self->order->currency_id($::instance_conf->get_currency_id());
1169 my %pat = $self->order->calculate_prices_and_taxes();
1170 $self->{taxes} = [];
1171 foreach my $tax_chart_id (keys %{ $pat{taxes} }) {
1172 my $tax = SL::DB::Manager::Tax->find_by(chart_id => $tax_chart_id);
1174 my @amount_keys = grep { $pat{amounts}->{$_}->{tax_id} == $tax->id } keys %{ $pat{amounts} };
1175 push(@{ $self->{taxes} }, { amount => $pat{taxes}->{$tax_chart_id},
1176 netamount => $pat{amounts}->{$amount_keys[0]}->{amount},
1180 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items}, @{$pat{items}};
1183 # get data for saving, printing, ..., that is not changed in the form
1185 # Only cvars for now.
1186 sub _get_unalterable_data {
1189 foreach my $item (@{ $self->order->items }) {
1190 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1191 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1192 foreach my $var (@{ $item->cvars_by_config }) {
1193 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1195 $item->parse_custom_variable_values;
1201 # And remove related files in the spool directory
1206 my $db = $self->order->db;
1208 $db->with_transaction(
1210 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1211 $self->order->delete;
1212 my $spool = $::lx_office_conf{paths}->{spool};
1213 unlink map { "$spool/$_" } @spoolfiles if $spool;
1216 }) || push(@{$errors}, $db->error);
1223 # And delete items that are deleted in the form.
1228 my $db = $self->order->db;
1230 $db->with_transaction(sub {
1231 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete};
1232 $self->order->save(cascade => 1);
1233 }) || push(@{$errors}, $db->error);
1242 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1243 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1244 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1247 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1250 $self->{all_projects} = SL::DB::Manager::Project->get_all(where => [ or => [ id => $self->order->globalproject_id,
1252 sort_by => 'projectnumber');
1253 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1255 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1256 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1257 $self->{periodic_invoices_status} = $self->_get_periodic_invoices_status($self->order->periodic_invoices_config);
1258 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1260 my $print_form = Form->new('');
1261 $print_form->{type} = $self->type;
1262 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1263 $print_form->{languages} = SL::DB::Manager::Language->get_all_sorted;
1264 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1265 form => $print_form,
1266 options => {dialog_name_prefix => 'print_options.',
1270 no_opendocument => 1,
1274 foreach my $item (@{$self->order->orderitems}) {
1275 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1276 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1277 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1280 if ($self->order->ordnumber && $::instance_conf->get_webdav) {
1281 my $webdav = SL::Webdav->new(
1282 type => $self->type,
1283 number => $self->order->ordnumber,
1285 my @all_objects = $webdav->get_all_objects;
1286 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1288 link => File::Spec->catfile($_->full_filedescriptor),
1292 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery edit_periodic_invoices_config);
1293 $self->_setup_edit_action_bar;
1296 sub _setup_edit_action_bar {
1297 my ($self, %params) = @_;
1299 my $deletion_allowed = (any { $self->type eq $_ } (_sales_quotation_type(), _request_quotation_type()))
1300 || (($self->type eq _sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
1301 || (($self->type eq _purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
1303 for my $bar ($::request->layout->get('actionbar')) {
1308 call => [ 'kivi.Order.save', $::instance_conf->get_order_warn_duplicate_parts ],
1309 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1312 t8('Save and Delivery Order'),
1313 call => [ 'kivi.Order.save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts ],
1314 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1315 only_if => (any { $self->type eq $_ } (_sales_order_type(), _purchase_order_type()))
1318 t8('Save and Invoice'),
1319 call => [ 'kivi.Order.save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1320 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1323 ], # end of combobox "Save"
1331 call => [ 'kivi.Order.show_print_options' ],
1335 call => [ 'kivi.Order.email' ],
1338 t8('Download attachments of all parts'),
1339 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1340 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1341 only_if => $::instance_conf->get_doc_storage,
1343 ], # end of combobox "Export"
1347 call => [ 'kivi.Order.delete_order' ],
1348 confirm => $::locale->text('Do you really want to delete this object?'),
1349 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1350 only_if => $deletion_allowed,
1357 my ($order, $pdf_ref, $params) = @_;
1361 my $print_form = Form->new('');
1362 $print_form->{type} = $order->type;
1363 $print_form->{formname} = $params->{formname} || $order->type;
1364 $print_form->{format} = $params->{format} || 'pdf';
1365 $print_form->{media} = $params->{media} || 'file';
1366 $print_form->{groupitems} = $params->{groupitems};
1367 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1369 $order->language($params->{language});
1370 $order->flatten_to_form($print_form, format_amounts => 1);
1372 # search for the template
1373 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1374 name => $print_form->{formname},
1375 email => $print_form->{media} eq 'email',
1376 language => $params->{language},
1377 printer_id => $print_form->{printer_id}, # todo
1380 if (!defined $template_file) {
1381 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);
1384 return @errors if scalar @errors;
1386 $print_form->throw_on_error(sub {
1388 $print_form->prepare_for_printing;
1390 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1391 template => $template_file,
1392 variables => $print_form,
1393 variable_content_types => {
1394 longdescription => 'html',
1395 partnotes => 'html',
1400 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->getMessage : $EVAL_ERROR;
1406 sub _get_files_for_email_dialog {
1409 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
1411 return %files if !$::instance_conf->get_doc_storage;
1413 if ($self->order->id) {
1414 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
1415 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
1416 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
1420 uniq_by { $_->{id} }
1422 +{ id => $_->part->id,
1423 partnumber => $_->part->partnumber }
1424 } @{$self->order->items_sorted};
1426 foreach my $part (@parts) {
1427 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
1428 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
1431 foreach my $key (keys %files) {
1432 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
1438 sub _make_periodic_invoices_config_from_yaml {
1439 my ($yaml_config) = @_;
1441 return if !$yaml_config;
1442 my $attr = YAML::Load($yaml_config);
1443 return if 'HASH' ne ref $attr;
1444 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
1448 sub _get_periodic_invoices_status {
1449 my ($self, $config) = @_;
1451 return if $self->type ne _sales_order_type();
1452 return t8('not configured') if !$config;
1454 my $active = ('HASH' eq ref $config) ? $config->{active}
1455 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
1456 : die "Cannot get status of periodic invoices config";
1458 return $active ? t8('active') : t8('inactive');
1461 sub _sales_order_type {
1465 sub _purchase_order_type {
1469 sub _sales_quotation_type {
1473 sub _request_quotation_type {
1474 'request_quotation';
1485 SL::Controller::Order - controller for orders
1489 This is a new form to enter orders, completely rewritten with the use
1490 of controller and java script techniques.
1492 The aim is to provide the user a better expirience and a faster flow
1493 of work. Also the code should be more readable, more reliable and
1502 One input row, so that input happens every time at the same place.
1506 Use of pickers where possible.
1510 Possibility to enter more than one item at once.
1514 Save order only on "save" (and "save and delivery order"-workflow). No
1515 hidden save on "print" or "email".
1519 Item list in a scrollable area, so that the workflow buttons stay at
1524 Reordering item rows with drag and drop is possible. Sorting item rows is
1525 possible (by partnumber, description, qty, sellprice and discount for now).
1529 No C<update> is necessary. All entries and calculations are managed
1530 with ajax-calls and the page does only reload on C<save>.
1534 User can see changes immediately, because of the use of java script
1545 =item * C<SL/Controller/Order.pm>
1549 =item * C<template/webpages/order/form.html>
1553 =item * C<template/webpages/order/tabs/basic_data.html>
1555 Main tab for basic_data.
1557 This is the only tab here for now. "linked records" and "webdav" tabs are
1558 reused from generic code.
1562 =item * C<template/webpages/order/tabs/_item_input.html>
1564 The input line for items
1566 =item * C<template/webpages/order/tabs/_row.html>
1568 One row for already entered items
1570 =item * C<template/webpages/order/tabs/_tax_row.html>
1572 Displaying tax information
1574 =item * C<template/webpages/order/tabs/_multi_items_dialog.html>
1576 Dialog for entering more than one item at once
1578 =item * C<template/webpages/order/tabs/_multi_items_result.html>
1580 Results for the filter in the multi items dialog
1582 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
1584 Dialog for selecting price and discount sources
1588 =item * C<js/kivi.Order.js>
1590 java script functions
1602 =item * customer/vendor details ('D'-button)
1604 =item * credit limit
1606 =item * more workflows (save as new, quotation, purchase order)
1608 =item * price sources: little symbols showing better price / better discount
1610 =item * select units in input row?
1612 =item * custom shipto address
1614 =item * language / part translations
1616 =item * access rights
1618 =item * display weights
1624 =item * optional client/user behaviour
1626 (transactions has to be set - department has to be set -
1627 force project if enabled in client config - transport cost reminder)
1631 =head1 KNOWN BUGS AND CAVEATS
1637 Customer discount is not displayed as a valid discount in price source popup
1638 (this might be a bug in price sources)
1640 (I cannot reproduce this (Bernd))
1644 No indication that <shift>-up/down expands/collapses second row.
1648 Inline creation of parts is not currently supported
1652 Table header is not sticky in the scrolling area.
1656 Sorting does not include C<position>, neither does reordering.
1658 This behavior was implemented intentionally. But we can discuss, which behavior
1659 should be implemented.
1663 C<show_multi_items_dialog> does not use the currently inserted string for
1668 The language selected in print or email dialog is not saved when the order is saved.
1672 =head1 To discuss / Nice to have
1678 How to expand/collapse second row. Now it can be done clicking the icon or
1683 Possibility to change longdescription in input row?
1687 Possibility to select PriceSources in input row?
1691 This controller uses a (changed) copy of the template for the PriceSource
1692 dialog. Maybe there could be used one code source.
1696 Rounding-differences between this controller (PriceTaxCalculator) and the old
1697 form. This is not only a problem here, but also in all parts using the PTC.
1698 There exists a ticket and a patch. This patch should be testet.
1702 An indicator, if the actual inputs are saved (like in an
1703 editor or on text processing application).
1707 A warning when leaving the page without saveing unchanged inputs.
1711 Workflows for delivery order and invoice are in the menu "Save", because the
1712 order is saved before opening the new document form. Nevertheless perhaps these
1713 workflow buttons should be put under "Workflows".
1720 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>