1 package SL::Controller::Order;
4 use parent qw(SL::Controller::Base);
6 use SL::Helper::Flash qw(flash_later);
7 use SL::Presenter::Tag qw(select_tag hidden_tag div_tag);
8 use SL::Locale::String qw(t8);
9 use SL::SessionFile::Random;
14 use SL::Util qw(trim);
21 use SL::DB::PartClassification;
22 use SL::DB::PartsGroup;
25 use SL::DB::RecordLink;
27 use SL::DB::Translation;
29 use SL::Helper::CreatePDF qw(:all);
30 use SL::Helper::PrintOptions;
31 use SL::Helper::ShippedQty;
32 use SL::Helper::UserPreferences::PositionsScrollbar;
33 use SL::Helper::UserPreferences::UpdatePositions;
35 use SL::Controller::Helper::GetModels;
37 use List::Util qw(first sum0);
38 use List::UtilsBy qw(sort_by uniq_by);
39 use List::MoreUtils qw(any none pairwise first_index);
40 use English qw(-no_match_vars);
45 use Rose::Object::MakeMethods::Generic
47 scalar => [ qw(item_ids_to_delete is_custom_shipto_to_delete) ],
48 'scalar --get_set_init' => [ qw(order valid_types type cv p all_price_factors search_cvpartnumber show_update_button part_picker_classification_ids) ],
53 __PACKAGE__->run_before('check_auth');
55 __PACKAGE__->run_before('recalc',
56 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
59 __PACKAGE__->run_before('get_unalterable_data',
60 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
71 $self->order->transdate(DateTime->now_local());
72 my $extra_days = $self->{type} eq 'sales_quotation' ? $::instance_conf->get_reqdate_interval :
73 $self->{type} eq 'sales_order' ? $::instance_conf->get_delivery_date_interval : 1;
75 if ( ($self->{type} eq 'sales_order' && $::instance_conf->get_deliverydate_on)
76 || ($self->{type} eq 'sales_quotation' && $::instance_conf->get_reqdate_on)
77 && (!$self->order->reqdate)) {
78 $self->order->reqdate(DateTime->today_local->next_workday(extra_days => $extra_days));
85 title => $self->get_title_for('add'),
86 %{$self->{template_args}}
90 # edit an existing order
98 # this is to edit an order from an unsaved order object
100 # set item ids to new fake id, to identify them as new items
101 foreach my $item (@{$self->order->items_sorted}) {
102 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
104 # trigger rendering values for second row as hidden, because they
105 # are loaded only on demand. So we need to keep the values from
107 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
114 title => $self->get_title_for('edit'),
115 %{$self->{template_args}}
119 # edit a collective order (consisting of one or more existing orders)
120 sub action_edit_collective {
124 my @multi_ids = map {
125 $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1}
126 } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form;
128 # fall back to add if no ids are given
129 if (scalar @multi_ids == 0) {
134 # fall back to save as new if only one id is given
135 if (scalar @multi_ids == 1) {
136 $self->order(SL::DB::Order->new(id => $multi_ids[0])->load);
137 $self->action_save_as_new();
141 # make new order from given orders
142 my @multi_orders = map { SL::DB::Order->new(id => $_)->load } @multi_ids;
143 $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders;
144 $self->order(SL::DB::Order->new_from_multi(\@multi_orders, sort_sources_by => 'transdate'));
146 $self->action_edit();
153 my $errors = $self->delete();
155 if (scalar @{ $errors }) {
156 $self->js->flash('error', $_) foreach @{ $errors };
157 return $self->js->render();
160 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been deleted')
161 : $self->type eq purchase_order_type() ? $::locale->text('The order has been deleted')
162 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been deleted')
163 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been deleted')
165 flash_later('info', $text);
167 my @redirect_params = (
172 $self->redirect_to(@redirect_params);
179 my $errors = $self->save();
181 if (scalar @{ $errors }) {
182 $self->js->flash('error', $_) foreach @{ $errors };
183 return $self->js->render();
186 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
187 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
188 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
189 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
191 flash_later('info', $text);
193 my @redirect_params = (
196 id => $self->order->id,
199 $self->redirect_to(@redirect_params);
202 # save the order as new document an open it for edit
203 sub action_save_as_new {
206 my $order = $self->order;
209 $self->js->flash('error', t8('This object has not been saved yet.'));
210 return $self->js->render();
213 # load order from db to check if values changed
214 my $saved_order = SL::DB::Order->new(id => $order->id)->load;
217 # Lets assign a new number if the user hasn't changed the previous one.
218 # If it has been changed manually then use it as-is.
219 $new_attrs{number} = (trim($order->number) eq $saved_order->number)
221 : trim($order->number);
223 # Clear transdate unless changed
224 $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
225 ? DateTime->today_local
228 # Set new reqdate unless changed if it is enabled in client config
229 if ($order->reqdate == $saved_order->reqdate) {
230 my $extra_days = $self->{type} eq 'sales_quotation' ? $::instance_conf->get_reqdate_interval :
231 $self->{type} eq 'sales_order' ? $::instance_conf->get_delivery_date_interval : 1;
233 if ( ($self->{type} eq 'sales_order' && !$::instance_conf->get_deliverydate_on)
234 || ($self->{type} eq 'sales_quotation' && !$::instance_conf->get_reqdate_on)) {
235 $new_attrs{reqdate} = '';
237 $new_attrs{reqdate} = DateTime->today_local->next_workday(extra_days => $extra_days);
240 $new_attrs{reqdate} = $order->reqdate;
244 $new_attrs{employee} = SL::DB::Manager::Employee->current;
246 # Create new record from current one
247 $self->order(SL::DB::Order->new_from($order, destination_type => $order->type, attributes => \%new_attrs));
249 # no linked records on save as new
250 delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
253 $self->action_save();
258 # This is called if "print" is pressed in the print dialog.
259 # If PDF creation was requested and succeeded, the pdf is offered for download
260 # via send_file (which uses ajax in this case).
264 my $errors = $self->save();
266 if (scalar @{ $errors }) {
267 $self->js->flash('error', $_) foreach @{ $errors };
268 return $self->js->render();
271 $self->js_reset_order_and_item_ids_after_save;
273 my $format = $::form->{print_options}->{format};
274 my $media = $::form->{print_options}->{media};
275 my $formname = $::form->{print_options}->{formname};
276 my $copies = $::form->{print_options}->{copies};
277 my $groupitems = $::form->{print_options}->{groupitems};
278 my $printer_id = $::form->{print_options}->{printer_id};
280 # only pdf and opendocument by now
281 if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf)) {
282 return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
285 # only screen or printer by now
286 if (none { $media eq $_ } qw(screen printer)) {
287 return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
290 # create a form for generate_attachment_filename
291 my $form = Form->new;
292 $form->{$self->nr_key()} = $self->order->number;
293 $form->{type} = $self->type;
294 $form->{format} = $format;
295 $form->{formname} = $formname;
296 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
297 my $pdf_filename = $form->generate_attachment_filename();
300 my @errors = generate_pdf($self->order, \$pdf, { format => $format,
301 formname => $formname,
302 language => $self->order->language,
303 printer_id => $printer_id,
304 groupitems => $groupitems });
305 if (scalar @errors) {
306 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
309 if ($media eq 'screen') {
311 $self->js->flash('info', t8('The PDF has been created'));
314 type => SL::MIME->mime_type_from_ext($pdf_filename),
315 name => $pdf_filename,
319 } elsif ($media eq 'printer') {
321 my $printer_id = $::form->{print_options}->{printer_id};
322 SL::DB::Printer->new(id => $printer_id)->load->print_document(
327 $self->js->flash('info', t8('The PDF has been printed'));
330 my @warnings = store_pdf_to_webdav_and_filemanagement($self->order, $pdf, $pdf_filename);
331 if (scalar @warnings) {
332 $self->js->flash('warning', $_) for @warnings;
335 $self->save_history('PRINTED');
338 ->run('kivi.ActionBar.setEnabled', '#save_and_email_action')
342 # open the email dialog
343 sub action_save_and_show_email_dialog {
346 my $errors = $self->save();
348 if (scalar @{ $errors }) {
349 $self->js->flash('error', $_) foreach @{ $errors };
350 return $self->js->render();
353 my $cv_method = $self->cv;
355 if (!$self->order->$cv_method) {
356 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'))
361 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
362 $email_form->{to} ||= $self->order->$cv_method->email;
363 $email_form->{cc} = $self->order->$cv_method->cc;
364 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
365 # Todo: get addresses from shipto, if any
367 my $form = Form->new;
368 $form->{$self->nr_key()} = $self->order->number;
369 $form->{cusordnumber} = $self->order->cusordnumber;
370 $form->{formname} = $self->type;
371 $form->{type} = $self->type;
372 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
373 $form->{language_id} = $self->order->language->id if $self->order->language;
374 $form->{format} = 'pdf';
376 $email_form->{subject} = $form->generate_email_subject();
377 $email_form->{attachment_filename} = $form->generate_attachment_filename();
378 $email_form->{message} = $form->generate_email_body();
379 $email_form->{js_send_function} = 'kivi.Order.send_email()';
381 my %files = $self->get_files_for_email_dialog();
382 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
383 email_form => $email_form,
384 show_bcc => $::auth->assert('email_bcc', 'may fail'),
386 is_customer => $self->cv eq 'customer',
390 ->run('kivi.Order.show_email_dialog', $dialog_html)
397 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
398 sub action_send_email {
401 my $errors = $self->save();
403 if (scalar @{ $errors }) {
404 $self->js->run('kivi.Order.close_email_dialog');
405 $self->js->flash('error', $_) foreach @{ $errors };
406 return $self->js->render();
409 $self->js_reset_order_and_item_ids_after_save;
411 my $email_form = delete $::form->{email_form};
412 my %field_names = (to => 'email');
414 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
416 # for Form::cleanup which may be called in Form::send_email
417 $::form->{cwd} = getcwd();
418 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
420 $::form->{$_} = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
421 $::form->{media} = 'email';
423 if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) {
425 my @errors = generate_pdf($self->order, \$pdf, {media => $::form->{media},
426 format => $::form->{print_options}->{format},
427 formname => $::form->{print_options}->{formname},
428 language => $self->order->language,
429 printer_id => $::form->{print_options}->{printer_id},
430 groupitems => $::form->{print_options}->{groupitems}});
431 if (scalar @errors) {
432 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
435 my @warnings = store_pdf_to_webdav_and_filemanagement($self->order, $pdf, $::form->{attachment_filename});
436 if (scalar @warnings) {
437 flash_later('warning', $_) for @warnings;
440 my $sfile = SL::SessionFile::Random->new(mode => "w");
441 $sfile->fh->print($pdf);
444 $::form->{tmpfile} = $sfile->file_name;
445 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
448 $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a linked record to the mail
449 $::form->send_email(\%::myconfig, 'pdf');
452 my $intnotes = $self->order->intnotes;
453 $intnotes .= "\n\n" if $self->order->intnotes;
454 $intnotes .= t8('[email]') . "\n";
455 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
456 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
457 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
458 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
459 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
460 $intnotes .= t8('Message') . ": " . $::form->{message};
462 $self->order->update_attributes(intnotes => $intnotes);
464 $self->save_history('MAILED');
466 flash_later('info', t8('The email has been sent.'));
468 my @redirect_params = (
471 id => $self->order->id,
474 $self->redirect_to(@redirect_params);
477 # open the periodic invoices config dialog
479 # If there are values in the form (i.e. dialog was opened before),
480 # then use this values. Create new ones, else.
481 sub action_show_periodic_invoices_config_dialog {
484 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
485 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
486 $config ||= SL::DB::PeriodicInvoicesConfig->new(periodicity => 'm',
487 order_value_periodicity => 'p', # = same as periodicity
488 start_date_as_date => $::form->{transdate_as_date} || $::form->current_date,
489 extend_automatically_by => 12,
491 email_subject => GenericTranslations->get(
492 language_id => $::form->{language_id},
493 translation_type =>"preset_text_periodic_invoices_email_subject"),
494 email_body => GenericTranslations->get(
495 language_id => $::form->{language_id},
496 translation_type =>"preset_text_periodic_invoices_email_body"),
498 $config->periodicity('m') if none { $_ eq $config->periodicity } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
499 $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
501 $::form->get_lists(printers => "ALL_PRINTERS",
502 charts => { key => 'ALL_CHARTS',
503 transdate => 'current_date' });
505 $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
507 if ($::form->{customer_id}) {
508 $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
509 $::form->{email_recipient_invoice_address} = SL::DB::Manager::Customer->find_by(id => $::form->{customer_id})->invoice_mail;
512 $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
514 popup_js_close_function => 'kivi.Order.close_periodic_invoices_config_dialog()',
515 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
520 # assign the values of the periodic invoices config dialog
521 # as yaml in the hidden tag and set the status.
522 sub action_assign_periodic_invoices_config {
525 $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
527 my $config = { active => $::form->{active} ? 1 : 0,
528 terminated => $::form->{terminated} ? 1 : 0,
529 direct_debit => $::form->{direct_debit} ? 1 : 0,
530 periodicity => (any { $_ eq $::form->{periodicity} } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES) ? $::form->{periodicity} : 'm',
531 order_value_periodicity => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
532 start_date_as_date => $::form->{start_date_as_date},
533 end_date_as_date => $::form->{end_date_as_date},
534 first_billing_date_as_date => $::form->{first_billing_date_as_date},
535 print => $::form->{print} ? 1 : 0,
536 printer_id => $::form->{print} ? $::form->{printer_id} * 1 : undef,
537 copies => $::form->{copies} * 1 ? $::form->{copies} : 1,
538 extend_automatically_by => $::form->{extend_automatically_by} * 1 || undef,
539 ar_chart_id => $::form->{ar_chart_id} * 1,
540 send_email => $::form->{send_email} ? 1 : 0,
541 email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
542 email_recipient_address => $::form->{email_recipient_address},
543 email_sender => $::form->{email_sender},
544 email_subject => $::form->{email_subject},
545 email_body => $::form->{email_body},
548 my $periodic_invoices_config = SL::YAML::Dump($config);
550 my $status = $self->get_periodic_invoices_status($config);
553 ->remove('#order_periodic_invoices_config')
554 ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
555 ->run('kivi.Order.close_periodic_invoices_config_dialog')
556 ->html('#periodic_invoices_status', $status)
557 ->flash('info', t8('The periodic invoices config has been assigned.'))
561 sub action_get_has_active_periodic_invoices {
564 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
565 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
567 my $has_active_periodic_invoices =
568 $self->type eq sales_order_type()
571 && (!$config->end_date || ($config->end_date > DateTime->today_local))
572 && $config->get_previous_billed_period_start_date;
574 $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
577 # save the order and redirect to the frontend subroutine for a new
579 sub action_save_and_delivery_order {
582 $self->save_and_redirect_to(
583 controller => 'oe.pl',
584 action => 'oe_delivery_order_from_order',
588 # save the order and redirect to the frontend subroutine for a new
590 sub action_save_and_invoice {
593 $self->save_and_redirect_to(
594 controller => 'oe.pl',
595 action => 'oe_invoice_from_order',
599 # workflow from sales order to sales quotation
600 sub action_sales_quotation {
601 $_[0]->workflow_sales_or_request_for_quotation();
604 # workflow from sales order to sales quotation
605 sub action_request_for_quotation {
606 $_[0]->workflow_sales_or_request_for_quotation();
609 # workflow from sales quotation to sales order
610 sub action_sales_order {
611 $_[0]->workflow_sales_or_purchase_order();
614 # workflow from rfq to purchase order
615 sub action_purchase_order {
616 $_[0]->workflow_sales_or_purchase_order();
619 # workflow from purchase order to ap transaction
620 sub action_save_and_ap_transaction {
623 $self->save_and_redirect_to(
624 controller => 'ap.pl',
625 action => 'add_from_purchase_order',
629 # set form elements in respect to a changed customer or vendor
631 # This action is called on an change of the customer/vendor picker.
632 sub action_customer_vendor_changed {
635 setup_order_from_cv($self->order);
638 my $cv_method = $self->cv;
640 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
641 $self->js->show('#cp_row');
643 $self->js->hide('#cp_row');
646 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
647 $self->js->show('#shipto_selection');
649 $self->js->hide('#shipto_selection');
652 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
655 ->replaceWith('#order_cp_id', $self->build_contact_select)
656 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
657 ->replaceWith('#shipto_inputs ', $self->build_shipto_inputs)
658 ->replaceWith('#business_info_row', $self->build_business_info_row)
659 ->val( '#order_taxzone_id', $self->order->taxzone_id)
660 ->val( '#order_taxincluded', $self->order->taxincluded)
661 ->val( '#order_currency_id', $self->order->currency_id)
662 ->val( '#order_payment_id', $self->order->payment_id)
663 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
664 ->val( '#order_intnotes', $self->order->intnotes)
665 ->val( '#order_language_id', $self->order->$cv_method->language_id)
666 ->focus( '#order_' . $self->cv . '_id')
667 ->run('kivi.Order.update_exchangerate');
669 $self->js_redisplay_amounts_and_taxes;
670 $self->js_redisplay_cvpartnumbers;
674 # open the dialog for customer/vendor details
675 sub action_show_customer_vendor_details_dialog {
678 my $is_customer = 'customer' eq $::form->{vc};
681 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
683 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
686 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
687 $details{discount_as_percent} = $cv->discount_as_percent;
688 $details{creditlimt} = $cv->creditlimit_as_number;
689 $details{business} = $cv->business->description if $cv->business;
690 $details{language} = $cv->language_obj->description if $cv->language_obj;
691 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
692 $details{payment_terms} = $cv->payment->description if $cv->payment;
693 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
695 foreach my $entry (@{ $cv->shipto }) {
696 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
698 foreach my $entry (@{ $cv->contacts }) {
699 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
702 $_[0]->render('common/show_vc_details', { layout => 0 },
703 is_customer => $is_customer,
708 # called if a unit in an existing item row is changed
709 sub action_unit_changed {
712 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
713 my $item = $self->order->items_sorted->[$idx];
715 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
716 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
721 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
722 $self->js_redisplay_line_values;
723 $self->js_redisplay_amounts_and_taxes;
727 # add an item row for a new item entered in the input row
728 sub action_add_item {
731 my $form_attr = $::form->{add_item};
733 return unless $form_attr->{parts_id};
735 my $item = new_item($self->order, $form_attr);
737 $self->order->add_items($item);
741 $self->get_item_cvpartnumber($item);
743 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
744 my $row_as_html = $self->p->render('order/tabs/_row',
750 if ($::form->{insert_before_item_id}) {
752 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
755 ->append('#row_table_id', $row_as_html);
758 if ( $item->part->is_assortment ) {
759 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
760 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
761 my $attr = { parts_id => $assortment_item->parts_id,
762 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
763 unit => $assortment_item->unit,
764 description => $assortment_item->part->description,
766 my $item = new_item($self->order, $attr);
768 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
769 $item->discount(1) unless $assortment_item->charge;
771 $self->order->add_items( $item );
773 $self->get_item_cvpartnumber($item);
774 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
775 my $row_as_html = $self->p->render('order/tabs/_row',
780 if ($::form->{insert_before_item_id}) {
782 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
785 ->append('#row_table_id', $row_as_html);
791 ->val('.add_item_input', '')
792 ->run('kivi.Order.init_row_handlers')
793 ->run('kivi.Order.renumber_positions')
794 ->focus('#add_item_parts_id_name');
796 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
798 $self->js_redisplay_amounts_and_taxes;
802 # add item rows for multiple items at once
803 sub action_add_multi_items {
806 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
807 return $self->js->render() unless scalar @form_attr;
810 foreach my $attr (@form_attr) {
811 my $item = new_item($self->order, $attr);
813 if ( $item->part->is_assortment ) {
814 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
815 my $attr = { parts_id => $assortment_item->parts_id,
816 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
817 unit => $assortment_item->unit,
818 description => $assortment_item->part->description,
820 my $item = new_item($self->order, $attr);
822 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
823 $item->discount(1) unless $assortment_item->charge;
828 $self->order->add_items(@items);
832 foreach my $item (@items) {
833 $self->get_item_cvpartnumber($item);
834 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
835 my $row_as_html = $self->p->render('order/tabs/_row',
841 if ($::form->{insert_before_item_id}) {
843 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
846 ->append('#row_table_id', $row_as_html);
851 ->run('kivi.Part.close_picker_dialogs')
852 ->run('kivi.Order.init_row_handlers')
853 ->run('kivi.Order.renumber_positions')
854 ->focus('#add_item_parts_id_name');
856 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
858 $self->js_redisplay_amounts_and_taxes;
862 # recalculate all linetotals, amounts and taxes and redisplay them
863 sub action_recalc_amounts_and_taxes {
868 $self->js_redisplay_line_values;
869 $self->js_redisplay_amounts_and_taxes;
873 sub action_update_exchangerate {
877 is_standard => $self->order->currency_id == $::instance_conf->get_currency_id,
878 currency_name => $self->order->currency->name,
879 exchangerate => $self->order->daily_exchangerate_as_null_number,
882 $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
885 # redisplay item rows if they are sorted by an attribute
886 sub action_reorder_items {
890 partnumber => sub { $_[0]->part->partnumber },
891 description => sub { $_[0]->description },
892 qty => sub { $_[0]->qty },
893 sellprice => sub { $_[0]->sellprice },
894 discount => sub { $_[0]->discount },
895 cvpartnumber => sub { $_[0]->{cvpartnumber} },
898 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
900 my $method = $sort_keys{$::form->{order_by}};
901 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
902 if ($::form->{sort_dir}) {
903 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
904 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
906 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
909 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
910 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
912 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
916 ->run('kivi.Order.redisplay_items', \@to_sort)
920 # show the popup to choose a price/discount source
921 sub action_price_popup {
924 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
925 my $item = $self->order->items_sorted->[$idx];
927 $self->render_price_dialog($item);
930 # load the second row for one or more items
932 # This action gets the html code for all items second rows by rendering a template for
933 # the second row and sets the html code via client js.
934 sub action_load_second_rows {
937 $self->recalc() if $self->order->is_sales; # for margin calculation
939 foreach my $item_id (@{ $::form->{item_ids} }) {
940 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
941 my $item = $self->order->items_sorted->[$idx];
943 $self->js_load_second_row($item, $item_id, 0);
946 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
951 # update description, notes and sellprice from master data
952 sub action_update_row_from_master_data {
955 foreach my $item_id (@{ $::form->{item_ids} }) {
956 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
957 my $item = $self->order->items_sorted->[$idx];
958 my $texts = get_part_texts($item->part, $self->order->language_id);
960 $item->description($texts->{description});
961 $item->longdescription($texts->{longdescription});
963 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
966 if ($item->part->is_assortment) {
967 # add assortment items with price 0, as the components carry the price
968 $price_src = $price_source->price_from_source("");
969 $price_src->price(0);
971 $price_src = $price_source->best_price
972 ? $price_source->best_price
973 : $price_source->price_from_source("");
974 $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate;
975 $price_src->price(0) if !$price_source->best_price;
979 $item->sellprice($price_src->price);
980 $item->active_price_source($price_src);
983 ->run('kivi.Order.update_sellprice', $item_id, $item->sellprice_as_number)
984 ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
985 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
986 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
988 if ($self->search_cvpartnumber) {
989 $self->get_item_cvpartnumber($item);
990 $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
995 $self->js_redisplay_line_values;
996 $self->js_redisplay_amounts_and_taxes;
1001 sub js_load_second_row {
1002 my ($self, $item, $item_id, $do_parse) = @_;
1005 # Parse values from form (they are formated while rendering (template)).
1006 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1007 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1008 foreach my $var (@{ $item->cvars_by_config }) {
1009 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1011 $item->parse_custom_variable_values;
1014 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1017 ->html('#second_row_' . $item_id, $row_as_html)
1018 ->data('#second_row_' . $item_id, 'loaded', 1);
1021 sub js_redisplay_line_values {
1024 my $is_sales = $self->order->is_sales;
1026 # sales orders with margins
1031 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1032 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1033 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1034 ]} @{ $self->order->items_sorted };
1038 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1039 ]} @{ $self->order->items_sorted };
1043 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1046 sub js_redisplay_amounts_and_taxes {
1049 if (scalar @{ $self->{taxes} }) {
1050 $self->js->show('#taxincluded_row_id');
1052 $self->js->hide('#taxincluded_row_id');
1055 if ($self->order->taxincluded) {
1056 $self->js->hide('#subtotal_row_id');
1058 $self->js->show('#subtotal_row_id');
1061 if ($self->order->is_sales) {
1062 my $is_neg = $self->order->marge_total < 0;
1064 ->html('#marge_total_id', $::form->format_amount(\%::myconfig, $self->order->marge_total, 2))
1065 ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
1066 ->action_if( $is_neg, 'addClass', '#marge_total_id', 'plus0')
1067 ->action_if( $is_neg, 'addClass', '#marge_percent_id', 'plus0')
1068 ->action_if( $is_neg, 'addClass', '#marge_percent_sign_id', 'plus0')
1069 ->action_if(!$is_neg, 'removeClass', '#marge_total_id', 'plus0')
1070 ->action_if(!$is_neg, 'removeClass', '#marge_percent_id', 'plus0')
1071 ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
1075 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
1076 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
1077 ->remove('.tax_row')
1078 ->insertBefore($self->build_tax_rows, '#amount_row_id');
1081 sub js_redisplay_cvpartnumbers {
1084 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1086 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1089 ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
1092 sub js_reset_order_and_item_ids_after_save {
1096 ->val('#id', $self->order->id)
1097 ->val('#converted_from_oe_id', '')
1098 ->val('#order_' . $self->nr_key(), $self->order->number);
1101 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1102 next if !$self->order->items_sorted->[$idx]->id;
1103 next if $form_item_id !~ m{^new};
1105 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1106 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1107 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1111 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1118 sub init_valid_types {
1119 [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
1125 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1126 die "Not a valid type for order";
1129 $self->type($::form->{type});
1135 my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer'
1136 : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
1137 : die "Not a valid type for order";
1142 sub init_search_cvpartnumber {
1145 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1146 my $search_cvpartnumber;
1147 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1148 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1150 return $search_cvpartnumber;
1153 sub init_show_update_button {
1156 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1167 sub init_all_price_factors {
1168 SL::DB::Manager::PriceFactor->get_all;
1171 sub init_part_picker_classification_ids {
1173 my $attribute = 'used_for_' . ($self->type =~ m{sales} ? 'sale' : 'purchase');
1175 return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => [ $attribute => 1 ]) } ];
1181 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
1183 my $right = $right_for->{ $self->type };
1184 $right ||= 'DOES_NOT_EXIST';
1186 $::auth->assert($right);
1189 # build the selection box for contacts
1191 # Needed, if customer/vendor changed.
1192 sub build_contact_select {
1195 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1196 value_key => 'cp_id',
1197 title_key => 'full_name_dep',
1198 default => $self->order->cp_id,
1200 style => 'width: 300px',
1204 # build the selection box for shiptos
1206 # Needed, if customer/vendor changed.
1207 sub build_shipto_select {
1210 select_tag('order.shipto_id',
1211 [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
1212 value_key => 'shipto_id',
1213 title_key => 'displayable_id',
1214 default => $self->order->shipto_id,
1216 style => 'width: 300px',
1220 # build the inputs for the cusom shipto dialog
1222 # Needed, if customer/vendor changed.
1223 sub build_shipto_inputs {
1226 my $content = $self->p->render('common/_ship_to_dialog',
1227 vc_obj => $self->order->customervendor,
1228 cs_obj => $self->order->custom_shipto,
1229 cvars => $self->order->custom_shipto->cvars_by_config,
1230 id_selector => '#order_shipto_id');
1232 div_tag($content, id => 'shipto_inputs');
1235 # render the info line for business
1237 # Needed, if customer/vendor changed.
1238 sub build_business_info_row
1240 $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
1243 # build the rows for displaying taxes
1245 # Called if amounts where recalculated and redisplayed.
1246 sub build_tax_rows {
1250 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
1251 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
1253 return $rows_as_html;
1257 sub render_price_dialog {
1258 my ($self, $record_item) = @_;
1260 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1264 'kivi.io.price_chooser_dialog',
1265 t8('Available Prices'),
1266 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1271 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1272 # $self->js->show('#dialog_flash_error');
1281 return if !$::form->{id};
1283 $self->order(SL::DB::Order->new(id => $::form->{id})->load);
1285 # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
1286 # You need a custom shipto object to call cvars_by_config to get the cvars.
1287 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
1289 return $self->order;
1292 # load or create a new order object
1294 # And assign changes from the form to this object.
1295 # If the order is loaded from db, check if items are deleted in the form,
1296 # remove them form the object and collect them for removing from db on saving.
1297 # Then create/update items from form (via make_item) and add them.
1301 # add_items adds items to an order with no items for saving, but they cannot
1302 # be retrieved via items until the order is saved. Adding empty items to new
1303 # order here solves this problem.
1305 $order = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1306 $order ||= SL::DB::Order->new(orderitems => [],
1307 quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())),
1308 currency_id => $::instance_conf->get_currency_id(),);
1310 my $cv_id_method = $self->cv . '_id';
1311 if (!$::form->{id} && $::form->{$cv_id_method}) {
1312 $order->$cv_id_method($::form->{$cv_id_method});
1313 setup_order_from_cv($order);
1316 my $form_orderitems = delete $::form->{order}->{orderitems};
1317 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1319 $order->assign_attributes(%{$::form->{order}});
1321 $self->setup_custom_shipto_from_form($order, $::form);
1323 if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) {
1324 my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
1325 $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
1328 # remove deleted items
1329 $self->item_ids_to_delete([]);
1330 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1331 my $item = $order->orderitems->[$idx];
1332 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1333 splice @{$order->orderitems}, $idx, 1;
1334 push @{$self->item_ids_to_delete}, $item->id;
1340 foreach my $form_attr (@{$form_orderitems}) {
1341 my $item = make_item($order, $form_attr);
1342 $item->position($pos);
1346 $order->add_items(grep {!$_->id} @items);
1351 # create or update items from form
1353 # Make item objects from form values. For items already existing read from db.
1354 # Create a new item else. And assign attributes.
1356 my ($record, $attr) = @_;
1359 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1361 my $is_new = !$item;
1363 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1364 # they cannot be retrieved via custom_variables until the order/orderitem is
1365 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1366 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1368 $item->assign_attributes(%$attr);
1371 my $texts = get_part_texts($item->part, $record->language_id);
1372 $item->longdescription($texts->{longdescription}) if !defined $attr->{longdescription};
1373 $item->project_id($record->globalproject_id) if !defined $attr->{project_id};
1374 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number};
1382 # This is used to add one item
1384 my ($record, $attr) = @_;
1386 my $item = SL::DB::OrderItem->new;
1388 # Remove attributes where the user left or set the inputs empty.
1389 # So these attributes will be undefined and we can distinguish them
1390 # from zero later on.
1391 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1392 delete $attr->{$_} if $attr->{$_} eq '';
1395 $item->assign_attributes(%$attr);
1397 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1398 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1400 $item->unit($part->unit) if !$item->unit;
1403 if ( $part->is_assortment ) {
1404 # add assortment items with price 0, as the components carry the price
1405 $price_src = $price_source->price_from_source("");
1406 $price_src->price(0);
1407 } elsif (defined $item->sellprice) {
1408 $price_src = $price_source->price_from_source("");
1409 $price_src->price($item->sellprice);
1411 $price_src = $price_source->best_price
1412 ? $price_source->best_price
1413 : $price_source->price_from_source("");
1414 $price_src->price($::form->round_amount($price_src->price / $record->exchangerate, 5)) if $record->exchangerate;
1415 $price_src->price(0) if !$price_source->best_price;
1419 if (defined $item->discount) {
1420 $discount_src = $price_source->discount_from_source("");
1421 $discount_src->discount($item->discount);
1423 $discount_src = $price_source->best_discount
1424 ? $price_source->best_discount
1425 : $price_source->discount_from_source("");
1426 $discount_src->discount(0) if !$price_source->best_discount;
1430 $new_attr{part} = $part;
1431 $new_attr{description} = $part->description if ! $item->description;
1432 $new_attr{qty} = 1.0 if ! $item->qty;
1433 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1434 $new_attr{sellprice} = $price_src->price;
1435 $new_attr{discount} = $discount_src->discount;
1436 $new_attr{active_price_source} = $price_src;
1437 $new_attr{active_discount_source} = $discount_src;
1438 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1439 $new_attr{project_id} = $record->globalproject_id;
1440 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1442 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1443 # they cannot be retrieved via custom_variables until the order/orderitem is
1444 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1445 $new_attr{custom_variables} = [];
1447 my $texts = get_part_texts($part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription});
1449 $item->assign_attributes(%new_attr, %{ $texts });
1454 sub setup_order_from_cv {
1457 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id));
1459 $order->intnotes($order->customervendor->notes);
1461 if ($order->is_sales) {
1462 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1463 $order->taxincluded(defined($order->customer->taxincluded_checked)
1464 ? $order->customer->taxincluded_checked
1465 : $::myconfig{taxincluded_checked});
1470 # setup custom shipto from form
1472 # The dialog returns form variables starting with 'shipto' and cvars starting
1473 # with 'shiptocvar_'.
1474 # Mark it to be deleted if a shipto from master data is selected
1475 # (i.e. order has a shipto).
1476 # Else, update or create a new custom shipto. If the fields are empty, it
1477 # will not be saved on save.
1478 sub setup_custom_shipto_from_form {
1479 my ($self, $order, $form) = @_;
1481 if ($order->shipto) {
1482 $self->is_custom_shipto_to_delete(1);
1484 my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1486 my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
1487 my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
1489 $custom_shipto->assign_attributes(%$shipto_attrs);
1490 $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
1494 # recalculate prices and taxes
1496 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1500 my %pat = $self->order->calculate_prices_and_taxes();
1502 $self->{taxes} = [];
1503 foreach my $tax_id (keys %{ $pat{taxes_by_tax_id} }) {
1504 my $netamount = sum0 map { $pat{amounts}->{$_}->{amount} } grep { $pat{amounts}->{$_}->{tax_id} == $tax_id } keys %{ $pat{amounts} };
1506 push(@{ $self->{taxes} }, { amount => $pat{taxes_by_tax_id}->{$tax_id},
1507 netamount => $netamount,
1508 tax => SL::DB::Tax->new(id => $tax_id)->load });
1510 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items_sorted}, @{$pat{items}};
1513 # get data for saving, printing, ..., that is not changed in the form
1515 # Only cvars for now.
1516 sub get_unalterable_data {
1519 foreach my $item (@{ $self->order->items }) {
1520 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1521 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1522 foreach my $var (@{ $item->cvars_by_config }) {
1523 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1525 $item->parse_custom_variable_values;
1531 # And remove related files in the spool directory
1536 my $db = $self->order->db;
1538 $db->with_transaction(
1540 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1541 $self->order->delete;
1542 my $spool = $::lx_office_conf{paths}->{spool};
1543 unlink map { "$spool/$_" } @spoolfiles if $spool;
1545 $self->save_history('DELETED');
1548 }) || push(@{$errors}, $db->error);
1555 # And delete items that are deleted in the form.
1560 my $db = $self->order->db;
1562 $db->with_transaction(sub {
1563 # delete custom shipto if it is to be deleted or if it is empty
1564 if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
1565 $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
1566 $self->order->custom_shipto(undef);
1569 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1570 $self->order->save(cascade => 1);
1573 if ($::form->{converted_from_oe_id}) {
1574 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1575 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1576 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1577 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
1578 $src->link_to_record($self->order);
1580 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1582 foreach (@{ $self->order->items_sorted }) {
1583 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1585 SL::DB::RecordLink->new(from_table => 'orderitems',
1586 from_id => $from_id,
1587 to_table => 'orderitems',
1595 $self->save_history('SAVED');
1598 }) || push(@{$errors}, $db->error);
1603 sub workflow_sales_or_request_for_quotation {
1607 my $errors = $self->save();
1609 if (scalar @{ $errors }) {
1610 $self->js->flash('error', $_) for @{ $errors };
1611 return $self->js->render();
1614 my $destination_type = $::form->{type} eq sales_order_type() ? sales_quotation_type() : request_quotation_type();
1616 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1617 $self->{converted_from_oe_id} = delete $::form->{id};
1619 # set item ids to new fake id, to identify them as new items
1620 foreach my $item (@{$self->order->items_sorted}) {
1621 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1625 $::form->{type} = $destination_type;
1626 $self->type($self->init_type);
1627 $self->cv ($self->init_cv);
1631 $self->get_unalterable_data();
1632 $self->pre_render();
1634 # trigger rendering values for second row as hidden, because they
1635 # are loaded only on demand. So we need to keep the values from the
1637 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1641 title => $self->get_title_for('edit'),
1642 %{$self->{template_args}}
1646 sub workflow_sales_or_purchase_order {
1650 my $errors = $self->save();
1652 if (scalar @{ $errors }) {
1653 $self->js->flash('error', $_) foreach @{ $errors };
1654 return $self->js->render();
1657 my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type()
1658 : $::form->{type} eq request_quotation_type() ? purchase_order_type()
1659 : $::form->{type} eq purchase_order_type() ? sales_order_type()
1660 : $::form->{type} eq sales_order_type() ? purchase_order_type()
1663 # check for direct delivery
1664 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
1666 if ( $::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()
1667 && $::form->{use_shipto} && $self->order->shipto) {
1668 $custom_shipto = $self->order->shipto->clone('SL::DB::Order');
1671 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1672 $self->{converted_from_oe_id} = delete $::form->{id};
1674 # set item ids to new fake id, to identify them as new items
1675 foreach my $item (@{$self->order->items_sorted}) {
1676 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1679 if ($::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()) {
1680 if ($::form->{use_shipto}) {
1681 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
1683 # remove any custom shipto if not wanted
1684 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1689 $::form->{type} = $destination_type;
1690 $self->type($self->init_type);
1691 $self->cv ($self->init_cv);
1695 $self->get_unalterable_data();
1696 $self->pre_render();
1698 # trigger rendering values for second row as hidden, because they
1699 # are loaded only on demand. So we need to keep the values from the
1701 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1705 title => $self->get_title_for('edit'),
1706 %{$self->{template_args}}
1714 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1715 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
1716 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1717 $self->{all_languages} = SL::DB::Manager::Language->get_all_sorted();
1718 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1721 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1724 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1726 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1727 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1728 $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
1729 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1730 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1732 my $print_form = Form->new('');
1733 $print_form->{type} = $self->type;
1734 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1735 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1736 form => $print_form,
1737 options => {dialog_name_prefix => 'print_options.',
1741 no_opendocument => 0,
1745 foreach my $item (@{$self->order->orderitems}) {
1746 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1747 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1748 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1751 if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
1752 # calculate shipped qtys here to prevent calling calculate for every item via the items method
1753 SL::Helper::ShippedQty->new->calculate($self->order)->write_to_objects;
1756 if ($self->order->number && $::instance_conf->get_webdav) {
1757 my $webdav = SL::Webdav->new(
1758 type => $self->type,
1759 number => $self->order->number,
1761 my @all_objects = $webdav->get_all_objects;
1762 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1764 link => File::Spec->catfile($_->full_filedescriptor),
1768 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1770 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
1771 edit_periodic_invoices_config calculate_qty kivi.Validator follow_up show_history);
1772 $self->setup_edit_action_bar;
1775 sub setup_edit_action_bar {
1776 my ($self, %params) = @_;
1778 my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
1779 || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
1780 || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
1782 for my $bar ($::request->layout->get('actionbar')) {
1787 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1788 $::instance_conf->get_order_warn_no_deliverydate,
1790 checks => [ 'kivi.Order.check_save_active_periodic_invoices', ['kivi.validate_form','#order_form'] ],
1794 call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1795 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1796 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1798 ], # end of combobox "Save"
1805 t8('Save and Quotation'),
1806 submit => [ '#order_form', { action => "Order/sales_quotation" } ],
1807 only_if => (any { $self->type eq $_ } (sales_order_type())),
1811 submit => [ '#order_form', { action => "Order/request_for_quotation" } ],
1812 only_if => (any { $self->type eq $_ } (purchase_order_type())),
1815 t8('Save and Sales Order'),
1816 submit => [ '#order_form', { action => "Order/sales_order" } ],
1817 only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
1820 t8('Save and Purchase Order'),
1821 call => [ 'kivi.Order.purchase_order_check_for_direct_delivery' ],
1822 only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
1825 t8('Save and Delivery Order'),
1826 call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1827 $::instance_conf->get_order_warn_no_deliverydate,
1829 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1830 only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type()))
1833 t8('Save and Invoice'),
1834 call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1835 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1838 t8('Save and AP Transaction'),
1839 call => [ 'kivi.Order.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
1840 only_if => (any { $self->type eq $_ } (purchase_order_type()))
1843 ], # end of combobox "Workflow"
1850 t8('Save and print'),
1851 call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts ],
1854 t8('Save and E-mail'),
1855 id => 'save_and_email_action',
1856 call => [ 'kivi.Order.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts ],
1857 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1860 t8('Download attachments of all parts'),
1861 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1862 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1863 only_if => $::instance_conf->get_doc_storage,
1865 ], # end of combobox "Export"
1869 call => [ 'kivi.Order.delete_order' ],
1870 confirm => $::locale->text('Do you really want to delete this object?'),
1871 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1872 only_if => $deletion_allowed,
1881 call => [ 'kivi.Order.follow_up_window' ],
1882 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1883 only_if => $::auth->assert('productivity', 1),
1887 call => [ 'set_history_window', $self->order->id, 'id' ],
1888 disabled => !$self->order->id ? t8('This record has not been saved yet.') : undef,
1890 ], # end of combobox "more"
1896 my ($order, $pdf_ref, $params) = @_;
1900 my $print_form = Form->new('');
1901 $print_form->{type} = $order->type;
1902 $print_form->{formname} = $params->{formname} || $order->type;
1903 $print_form->{format} = $params->{format} || 'pdf';
1904 $print_form->{media} = $params->{media} || 'file';
1905 $print_form->{groupitems} = $params->{groupitems};
1906 $print_form->{printer_id} = $params->{printer_id};
1907 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1909 $order->language($params->{language});
1910 $order->flatten_to_form($print_form, format_amounts => 1);
1914 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
1915 $template_ext = 'odt';
1916 $template_type = 'OpenDocument';
1919 # search for the template
1920 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1921 name => $print_form->{formname},
1922 extension => $template_ext,
1923 email => $print_form->{media} eq 'email',
1924 language => $params->{language},
1925 printer_id => $print_form->{printer_id},
1928 if (!defined $template_file) {
1929 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);
1932 return @errors if scalar @errors;
1934 $print_form->throw_on_error(sub {
1936 $print_form->prepare_for_printing;
1938 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1939 format => $print_form->{format},
1940 template_type => $template_type,
1941 template => $template_file,
1942 variables => $print_form,
1943 variable_content_types => {
1944 longdescription => 'html',
1945 partnotes => 'html',
1950 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
1956 sub get_files_for_email_dialog {
1959 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
1961 return %files if !$::instance_conf->get_doc_storage;
1963 if ($self->order->id) {
1964 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
1965 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
1966 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
1970 uniq_by { $_->{id} }
1972 +{ id => $_->part->id,
1973 partnumber => $_->part->partnumber }
1974 } @{$self->order->items_sorted};
1976 foreach my $part (@parts) {
1977 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
1978 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
1981 foreach my $key (keys %files) {
1982 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
1988 sub make_periodic_invoices_config_from_yaml {
1989 my ($yaml_config) = @_;
1991 return if !$yaml_config;
1992 my $attr = SL::YAML::Load($yaml_config);
1993 return if 'HASH' ne ref $attr;
1994 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
1998 sub get_periodic_invoices_status {
1999 my ($self, $config) = @_;
2001 return if $self->type ne sales_order_type();
2002 return t8('not configured') if !$config;
2004 my $active = ('HASH' eq ref $config) ? $config->{active}
2005 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
2006 : die "Cannot get status of periodic invoices config";
2008 return $active ? t8('active') : t8('inactive');
2012 my ($self, $action) = @_;
2014 return '' if none { lc($action)} qw(add edit);
2017 # $::locale->text("Add Sales Order");
2018 # $::locale->text("Add Purchase Order");
2019 # $::locale->text("Add Quotation");
2020 # $::locale->text("Add Request for Quotation");
2021 # $::locale->text("Edit Sales Order");
2022 # $::locale->text("Edit Purchase Order");
2023 # $::locale->text("Edit Quotation");
2024 # $::locale->text("Edit Request for Quotation");
2026 $action = ucfirst(lc($action));
2027 return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order")
2028 : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order")
2029 : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation")
2030 : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
2034 sub get_item_cvpartnumber {
2035 my ($self, $item) = @_;
2037 return if !$self->search_cvpartnumber;
2038 return if !$self->order->customervendor;
2040 if ($self->cv eq 'vendor') {
2041 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
2042 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
2043 } elsif ($self->cv eq 'customer') {
2044 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
2045 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
2049 sub get_part_texts {
2050 my ($part_or_id, $language_or_id, %defaults) = @_;
2052 my $part = ref($part_or_id) ? $part_or_id : SL::DB::Part->load_cached($part_or_id);
2053 my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id;
2055 description => $defaults{description} // $part->description,
2056 longdescription => $defaults{longdescription} // $part->notes,
2059 return $texts unless $language_id;
2061 my $translation = SL::DB::Manager::Translation->get_first(
2063 parts_id => $part->id,
2064 language_id => $language_id,
2067 $texts->{description} = $translation->translation if $translation && $translation->translation;
2068 $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription;
2073 sub sales_order_type {
2077 sub purchase_order_type {
2081 sub sales_quotation_type {
2085 sub request_quotation_type {
2086 'request_quotation';
2090 return $_[0]->type eq sales_order_type() ? 'ordnumber'
2091 : $_[0]->type eq purchase_order_type() ? 'ordnumber'
2092 : $_[0]->type eq sales_quotation_type() ? 'quonumber'
2093 : $_[0]->type eq request_quotation_type() ? 'quonumber'
2097 sub save_and_redirect_to {
2098 my ($self, %params) = @_;
2100 my $errors = $self->save();
2102 if (scalar @{ $errors }) {
2103 $self->js->flash('error', $_) foreach @{ $errors };
2104 return $self->js->render();
2107 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
2108 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
2109 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
2110 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
2112 flash_later('info', $text);
2114 $self->redirect_to(%params, id => $self->order->id);
2118 my ($self, $addition) = @_;
2120 my $number_type = $self->order->type =~ m{order} ? 'ordnumber' : 'quonumber';
2121 my $snumbers = $number_type . '_' . $self->order->$number_type;
2123 SL::DB::History->new(
2124 trans_id => $self->order->id,
2125 employee_id => SL::DB::Manager::Employee->current->id,
2126 what_done => $self->order->type,
2127 snumbers => $snumbers,
2128 addition => $addition,
2132 sub store_pdf_to_webdav_and_filemanagement {
2133 my($order, $content, $filename) = @_;
2137 # copy file to webdav folder
2138 if ($order->number && $::instance_conf->get_webdav_documents) {
2139 my $webdav = SL::Webdav->new(
2140 type => $order->type,
2141 number => $order->number,
2143 my $webdav_file = SL::Webdav::File->new(
2145 filename => $filename,
2148 $webdav_file->store(data => \$content);
2151 push @errors, t8('Storing PDF to webdav folder failed: #1', $@);
2154 if ($order->id && $::instance_conf->get_doc_storage) {
2156 SL::File->save(object_id => $order->id,
2157 object_type => $order->type,
2158 mime_type => 'application/pdf',
2159 source => 'created',
2160 file_type => 'document',
2161 file_name => $filename,
2162 file_contents => $content);
2165 push @errors, t8('Storing PDF in storage backend failed: #1', $@);
2180 SL::Controller::Order - controller for orders
2184 This is a new form to enter orders, completely rewritten with the use
2185 of controller and java script techniques.
2187 The aim is to provide the user a better experience and a faster workflow. Also
2188 the code should be more readable, more reliable and better to maintain.
2196 One input row, so that input happens every time at the same place.
2200 Use of pickers where possible.
2204 Possibility to enter more than one item at once.
2208 Item list in a scrollable area, so that the workflow buttons stay at
2213 Reordering item rows with drag and drop is possible. Sorting item rows is
2214 possible (by partnumber, description, qty, sellprice and discount for now).
2218 No C<update> is necessary. All entries and calculations are managed
2219 with ajax-calls and the page only reloads on C<save>.
2223 User can see changes immediately, because of the use of java script
2234 =item * C<SL/Controller/Order.pm>
2238 =item * C<template/webpages/order/form.html>
2242 =item * C<template/webpages/order/tabs/basic_data.html>
2244 Main tab for basic_data.
2246 This is the only tab here for now. "linked records" and "webdav" tabs are
2247 reused from generic code.
2251 =item * C<template/webpages/order/tabs/_business_info_row.html>
2253 For displaying information on business type
2255 =item * C<template/webpages/order/tabs/_item_input.html>
2257 The input line for items
2259 =item * C<template/webpages/order/tabs/_row.html>
2261 One row for already entered items
2263 =item * C<template/webpages/order/tabs/_tax_row.html>
2265 Displaying tax information
2267 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
2269 Dialog for selecting price and discount sources
2273 =item * C<js/kivi.Order.js>
2275 java script functions
2285 =item * price sources: little symbols showing better price / better discount
2287 =item * select units in input row?
2289 =item * check for direct delivery (workflow sales order -> purchase order)
2291 =item * access rights
2293 =item * display weights
2297 =item * optional client/user behaviour
2299 (transactions has to be set - department has to be set -
2300 force project if enabled in client config - transport cost reminder)
2304 =head1 KNOWN BUGS AND CAVEATS
2310 Customer discount is not displayed as a valid discount in price source popup
2311 (this might be a bug in price sources)
2313 (I cannot reproduce this (Bernd))
2317 No indication that <shift>-up/down expands/collapses second row.
2321 Inline creation of parts is not currently supported
2325 Table header is not sticky in the scrolling area.
2329 Sorting does not include C<position>, neither does reordering.
2331 This behavior was implemented intentionally. But we can discuss, which behavior
2332 should be implemented.
2336 =head1 To discuss / Nice to have
2342 How to expand/collapse second row. Now it can be done clicking the icon or
2347 Possibility to select PriceSources in input row?
2351 This controller uses a (changed) copy of the template for the PriceSource
2352 dialog. Maybe there could be used one code source.
2356 Rounding-differences between this controller (PriceTaxCalculator) and the old
2357 form. This is not only a problem here, but also in all parts using the PTC.
2358 There exists a ticket and a patch. This patch should be testet.
2362 An indicator, if the actual inputs are saved (like in an
2363 editor or on text processing application).
2367 A warning when leaving the page without saveing unchanged inputs.
2374 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>