1 package SL::Controller::Order;
4 use parent qw(SL::Controller::Base);
6 use SL::Helper::Flash qw(flash_later);
7 use SL::Presenter::Tag qw(select_tag hidden_tag div_tag);
8 use SL::Locale::String qw(t8);
9 use SL::SessionFile::Random;
14 use SL::Util qw(trim);
21 use SL::DB::PartClassification;
22 use SL::DB::PartsGroup;
25 use SL::DB::RecordLink;
27 use SL::DB::Translation;
29 use SL::Helper::CreatePDF qw(:all);
30 use SL::Helper::PrintOptions;
31 use SL::Helper::ShippedQty;
32 use SL::Helper::UserPreferences::PositionsScrollbar;
33 use SL::Helper::UserPreferences::UpdatePositions;
35 use SL::Controller::Helper::GetModels;
37 use List::Util qw(first sum0);
38 use List::UtilsBy qw(sort_by uniq_by);
39 use List::MoreUtils qw(any none pairwise first_index);
40 use English qw(-no_match_vars);
45 use Rose::Object::MakeMethods::Generic
47 scalar => [ qw(item_ids_to_delete is_custom_shipto_to_delete) ],
48 'scalar --get_set_init' => [ qw(order valid_types type cv p all_price_factors search_cvpartnumber show_update_button part_picker_classification_ids) ],
53 __PACKAGE__->run_before('check_auth');
55 __PACKAGE__->run_before('recalc',
56 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
59 __PACKAGE__->run_before('get_unalterable_data',
60 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
71 $self->order->transdate(DateTime->now_local());
72 my $extra_days = $self->{type} eq 'sales_quotation' ? $::instance_conf->get_reqdate_interval :
73 $self->{type} eq 'sales_order' ? $::instance_conf->get_delivery_date_interval : 1;
74 $self->order->reqdate(DateTime->today_local->next_workday(extra_days => $extra_days)) if !$self->order->reqdate;
80 title => $self->get_title_for('add'),
81 %{$self->{template_args}}
85 # edit an existing order
93 # this is to edit an order from an unsaved order object
95 # set item ids to new fake id, to identify them as new items
96 foreach my $item (@{$self->order->items_sorted}) {
97 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
99 # trigger rendering values for second row as hidden, because they
100 # are loaded only on demand. So we need to keep the values from
102 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
109 title => $self->get_title_for('edit'),
110 %{$self->{template_args}}
114 # edit a collective order (consisting of one or more existing orders)
115 sub action_edit_collective {
119 my @multi_ids = map {
120 $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1}
121 } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form;
123 # fall back to add if no ids are given
124 if (scalar @multi_ids == 0) {
129 # fall back to save as new if only one id is given
130 if (scalar @multi_ids == 1) {
131 $self->order(SL::DB::Order->new(id => $multi_ids[0])->load);
132 $self->action_save_as_new();
136 # make new order from given orders
137 my @multi_orders = map { SL::DB::Order->new(id => $_)->load } @multi_ids;
138 $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders;
139 $self->order(SL::DB::Order->new_from_multi(\@multi_orders, sort_sources_by => 'transdate'));
141 $self->action_edit();
148 my $errors = $self->delete();
150 if (scalar @{ $errors }) {
151 $self->js->flash('error', $_) foreach @{ $errors };
152 return $self->js->render();
155 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been deleted')
156 : $self->type eq purchase_order_type() ? $::locale->text('The order has been deleted')
157 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been deleted')
158 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been deleted')
160 flash_later('info', $text);
162 my @redirect_params = (
167 $self->redirect_to(@redirect_params);
174 my $errors = $self->save();
176 if (scalar @{ $errors }) {
177 $self->js->flash('error', $_) foreach @{ $errors };
178 return $self->js->render();
181 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
182 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
183 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
184 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
186 flash_later('info', $text);
188 my @redirect_params = (
191 id => $self->order->id,
194 $self->redirect_to(@redirect_params);
197 # save the order as new document an open it for edit
198 sub action_save_as_new {
201 my $order = $self->order;
204 $self->js->flash('error', t8('This object has not been saved yet.'));
205 return $self->js->render();
208 # load order from db to check if values changed
209 my $saved_order = SL::DB::Order->new(id => $order->id)->load;
212 # Lets assign a new number if the user hasn't changed the previous one.
213 # If it has been changed manually then use it as-is.
214 $new_attrs{number} = (trim($order->number) eq $saved_order->number)
216 : trim($order->number);
218 # Clear transdate unless changed
219 $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
220 ? DateTime->today_local
223 # Set new reqdate unless changed
224 if ($order->reqdate == $saved_order->reqdate) {
225 my $extra_days = $self->{type} eq 'sales_quotation' ? $::instance_conf->get_reqdate_interval :
226 $self->{type} eq 'sales_order' ? $::instance_conf->get_delivery_date_interval : 1;
227 $new_attrs{reqdate} = DateTime->today_local->next_workday(extra_days => $extra_days);
229 $new_attrs{reqdate} = $order->reqdate;
233 $new_attrs{employee} = SL::DB::Manager::Employee->current;
235 # Create new record from current one
236 $self->order(SL::DB::Order->new_from($order, destination_type => $order->type, attributes => \%new_attrs));
238 # no linked records on save as new
239 delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
242 $self->action_save();
247 # This is called if "print" is pressed in the print dialog.
248 # If PDF creation was requested and succeeded, the pdf is offered for download
249 # via send_file (which uses ajax in this case).
253 my $errors = $self->save();
255 if (scalar @{ $errors }) {
256 $self->js->flash('error', $_) foreach @{ $errors };
257 return $self->js->render();
260 $self->js_reset_order_and_item_ids_after_save;
262 my $format = $::form->{print_options}->{format};
263 my $media = $::form->{print_options}->{media};
264 my $formname = $::form->{print_options}->{formname};
265 my $copies = $::form->{print_options}->{copies};
266 my $groupitems = $::form->{print_options}->{groupitems};
267 my $printer_id = $::form->{print_options}->{printer_id};
269 # only pdf and opendocument by now
270 if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf)) {
271 return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
274 # only screen or printer by now
275 if (none { $media eq $_ } qw(screen printer)) {
276 return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
279 # create a form for generate_attachment_filename
280 my $form = Form->new;
281 $form->{$self->nr_key()} = $self->order->number;
282 $form->{type} = $self->type;
283 $form->{format} = $format;
284 $form->{formname} = $formname;
285 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
286 my $pdf_filename = $form->generate_attachment_filename();
289 my @errors = generate_pdf($self->order, \$pdf, { format => $format,
290 formname => $formname,
291 language => $self->order->language,
292 printer_id => $printer_id,
293 groupitems => $groupitems });
294 if (scalar @errors) {
295 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
298 if ($media eq 'screen') {
300 $self->js->flash('info', t8('The PDF has been created'));
303 type => SL::MIME->mime_type_from_ext($pdf_filename),
304 name => $pdf_filename,
308 } elsif ($media eq 'printer') {
310 my $printer_id = $::form->{print_options}->{printer_id};
311 SL::DB::Printer->new(id => $printer_id)->load->print_document(
316 $self->js->flash('info', t8('The PDF has been printed'));
319 my @warnings = store_pdf_to_webdav_and_filemanegement($self->order, $pdf, $pdf_filename);
320 if (scalar @warnings) {
321 $self->js->flash('warning', $_) for @warnings;
324 $self->save_history('PRINTED');
327 ->run('kivi.ActionBar.setEnabled', '#save_and_email_action')
331 # open the email dialog
332 sub action_save_and_show_email_dialog {
335 my $errors = $self->save();
337 if (scalar @{ $errors }) {
338 $self->js->flash('error', $_) foreach @{ $errors };
339 return $self->js->render();
342 my $cv_method = $self->cv;
344 if (!$self->order->$cv_method) {
345 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'))
350 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
351 $email_form->{to} ||= $self->order->$cv_method->email;
352 $email_form->{cc} = $self->order->$cv_method->cc;
353 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
354 # Todo: get addresses from shipto, if any
356 my $form = Form->new;
357 $form->{$self->nr_key()} = $self->order->number;
358 $form->{cusordnumber} = $self->order->cusordnumber;
359 $form->{formname} = $self->type;
360 $form->{type} = $self->type;
361 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
362 $form->{language_id} = $self->order->language->id if $self->order->language;
363 $form->{format} = 'pdf';
365 $email_form->{subject} = $form->generate_email_subject();
366 $email_form->{attachment_filename} = $form->generate_attachment_filename();
367 $email_form->{message} = $form->generate_email_body();
368 $email_form->{js_send_function} = 'kivi.Order.send_email()';
370 my %files = $self->get_files_for_email_dialog();
371 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
372 email_form => $email_form,
373 show_bcc => $::auth->assert('email_bcc', 'may fail'),
375 is_customer => $self->cv eq 'customer',
379 ->run('kivi.Order.show_email_dialog', $dialog_html)
386 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
387 sub action_send_email {
390 my $errors = $self->save();
392 if (scalar @{ $errors }) {
393 $self->js->run('kivi.Order.close_email_dialog');
394 $self->js->flash('error', $_) foreach @{ $errors };
395 return $self->js->render();
398 $self->js_reset_order_and_item_ids_after_save;
400 my $email_form = delete $::form->{email_form};
401 my %field_names = (to => 'email');
403 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
405 # for Form::cleanup which may be called in Form::send_email
406 $::form->{cwd} = getcwd();
407 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
409 $::form->{$_} = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
410 $::form->{media} = 'email';
412 if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) {
414 my @errors = generate_pdf($self->order, \$pdf, {media => $::form->{media},
415 format => $::form->{print_options}->{format},
416 formname => $::form->{print_options}->{formname},
417 language => $self->order->language,
418 printer_id => $::form->{print_options}->{printer_id},
419 groupitems => $::form->{print_options}->{groupitems}});
420 if (scalar @errors) {
421 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
424 my @warnings = store_pdf_to_webdav_and_filemanegement($self->order, $pdf, $::form->{attachment_filename});
425 if (scalar @warnings) {
426 flash_later('warning', $_) for @warnings;
429 my $sfile = SL::SessionFile::Random->new(mode => "w");
430 $sfile->fh->print($pdf);
433 $::form->{tmpfile} = $sfile->file_name;
434 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
437 $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a linked record to the mail
438 $::form->send_email(\%::myconfig, 'pdf');
441 my $intnotes = $self->order->intnotes;
442 $intnotes .= "\n\n" if $self->order->intnotes;
443 $intnotes .= t8('[email]') . "\n";
444 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
445 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
446 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
447 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
448 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
449 $intnotes .= t8('Message') . ": " . $::form->{message};
451 $self->order->update_attributes(intnotes => $intnotes);
453 $self->save_history('MAILED');
455 flash_later('info', t8('The email has been sent.'));
457 my @redirect_params = (
460 id => $self->order->id,
463 $self->redirect_to(@redirect_params);
466 # open the periodic invoices config dialog
468 # If there are values in the form (i.e. dialog was opened before),
469 # then use this values. Create new ones, else.
470 sub action_show_periodic_invoices_config_dialog {
473 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
474 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
475 $config ||= SL::DB::PeriodicInvoicesConfig->new(periodicity => 'm',
476 order_value_periodicity => 'p', # = same as periodicity
477 start_date_as_date => $::form->{transdate_as_date} || $::form->current_date,
478 extend_automatically_by => 12,
480 email_subject => GenericTranslations->get(
481 language_id => $::form->{language_id},
482 translation_type =>"preset_text_periodic_invoices_email_subject"),
483 email_body => GenericTranslations->get(
484 language_id => $::form->{language_id},
485 translation_type =>"preset_text_periodic_invoices_email_body"),
487 $config->periodicity('m') if none { $_ eq $config->periodicity } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
488 $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
490 $::form->get_lists(printers => "ALL_PRINTERS",
491 charts => { key => 'ALL_CHARTS',
492 transdate => 'current_date' });
494 $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
496 if ($::form->{customer_id}) {
497 $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
498 $::form->{email_recipient_invoice_address} = SL::DB::Manager::Customer->find_by(id => $::form->{customer_id})->invoice_mail;
501 $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
503 popup_js_close_function => 'kivi.Order.close_periodic_invoices_config_dialog()',
504 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
509 # assign the values of the periodic invoices config dialog
510 # as yaml in the hidden tag and set the status.
511 sub action_assign_periodic_invoices_config {
514 $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
516 my $config = { active => $::form->{active} ? 1 : 0,
517 terminated => $::form->{terminated} ? 1 : 0,
518 direct_debit => $::form->{direct_debit} ? 1 : 0,
519 periodicity => (any { $_ eq $::form->{periodicity} } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES) ? $::form->{periodicity} : 'm',
520 order_value_periodicity => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
521 start_date_as_date => $::form->{start_date_as_date},
522 end_date_as_date => $::form->{end_date_as_date},
523 first_billing_date_as_date => $::form->{first_billing_date_as_date},
524 print => $::form->{print} ? 1 : 0,
525 printer_id => $::form->{print} ? $::form->{printer_id} * 1 : undef,
526 copies => $::form->{copies} * 1 ? $::form->{copies} : 1,
527 extend_automatically_by => $::form->{extend_automatically_by} * 1 || undef,
528 ar_chart_id => $::form->{ar_chart_id} * 1,
529 send_email => $::form->{send_email} ? 1 : 0,
530 email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
531 email_recipient_address => $::form->{email_recipient_address},
532 email_sender => $::form->{email_sender},
533 email_subject => $::form->{email_subject},
534 email_body => $::form->{email_body},
537 my $periodic_invoices_config = SL::YAML::Dump($config);
539 my $status = $self->get_periodic_invoices_status($config);
542 ->remove('#order_periodic_invoices_config')
543 ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
544 ->run('kivi.Order.close_periodic_invoices_config_dialog')
545 ->html('#periodic_invoices_status', $status)
546 ->flash('info', t8('The periodic invoices config has been assigned.'))
550 sub action_get_has_active_periodic_invoices {
553 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
554 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
556 my $has_active_periodic_invoices =
557 $self->type eq sales_order_type()
560 && (!$config->end_date || ($config->end_date > DateTime->today_local))
561 && $config->get_previous_billed_period_start_date;
563 $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
566 # save the order and redirect to the frontend subroutine for a new
568 sub action_save_and_delivery_order {
571 $self->save_and_redirect_to(
572 controller => 'oe.pl',
573 action => 'oe_delivery_order_from_order',
577 # save the order and redirect to the frontend subroutine for a new
579 sub action_save_and_invoice {
582 $self->save_and_redirect_to(
583 controller => 'oe.pl',
584 action => 'oe_invoice_from_order',
588 # workflow from sales order to sales quotation
589 sub action_sales_quotation {
590 $_[0]->workflow_sales_or_request_for_quotation();
593 # workflow from sales order to sales quotation
594 sub action_request_for_quotation {
595 $_[0]->workflow_sales_or_request_for_quotation();
598 # workflow from sales quotation to sales order
599 sub action_sales_order {
600 $_[0]->workflow_sales_or_purchase_order();
603 # workflow from rfq to purchase order
604 sub action_purchase_order {
605 $_[0]->workflow_sales_or_purchase_order();
608 # workflow from purchase order to ap transaction
609 sub action_save_and_ap_transaction {
612 $self->save_and_redirect_to(
613 controller => 'ap.pl',
614 action => 'add_from_purchase_order',
618 # set form elements in respect to a changed customer or vendor
620 # This action is called on an change of the customer/vendor picker.
621 sub action_customer_vendor_changed {
624 setup_order_from_cv($self->order);
627 my $cv_method = $self->cv;
629 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
630 $self->js->show('#cp_row');
632 $self->js->hide('#cp_row');
635 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
636 $self->js->show('#shipto_selection');
638 $self->js->hide('#shipto_selection');
641 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
644 ->replaceWith('#order_cp_id', $self->build_contact_select)
645 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
646 ->replaceWith('#shipto_inputs ', $self->build_shipto_inputs)
647 ->replaceWith('#business_info_row', $self->build_business_info_row)
648 ->val( '#order_taxzone_id', $self->order->taxzone_id)
649 ->val( '#order_taxincluded', $self->order->taxincluded)
650 ->val( '#order_currency_id', $self->order->currency_id)
651 ->val( '#order_payment_id', $self->order->payment_id)
652 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
653 ->val( '#order_intnotes', $self->order->intnotes)
654 ->val( '#order_language_id', $self->order->$cv_method->language_id)
655 ->focus( '#order_' . $self->cv . '_id')
656 ->run('kivi.Order.update_exchangerate');
658 $self->js_redisplay_amounts_and_taxes;
659 $self->js_redisplay_cvpartnumbers;
663 # open the dialog for customer/vendor details
664 sub action_show_customer_vendor_details_dialog {
667 my $is_customer = 'customer' eq $::form->{vc};
670 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
672 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
675 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
676 $details{discount_as_percent} = $cv->discount_as_percent;
677 $details{creditlimt} = $cv->creditlimit_as_number;
678 $details{business} = $cv->business->description if $cv->business;
679 $details{language} = $cv->language_obj->description if $cv->language_obj;
680 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
681 $details{payment_terms} = $cv->payment->description if $cv->payment;
682 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
684 foreach my $entry (@{ $cv->shipto }) {
685 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
687 foreach my $entry (@{ $cv->contacts }) {
688 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
691 $_[0]->render('common/show_vc_details', { layout => 0 },
692 is_customer => $is_customer,
697 # called if a unit in an existing item row is changed
698 sub action_unit_changed {
701 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
702 my $item = $self->order->items_sorted->[$idx];
704 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
705 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
710 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
711 $self->js_redisplay_line_values;
712 $self->js_redisplay_amounts_and_taxes;
716 # add an item row for a new item entered in the input row
717 sub action_add_item {
720 my $form_attr = $::form->{add_item};
722 return unless $form_attr->{parts_id};
724 my $item = new_item($self->order, $form_attr);
726 $self->order->add_items($item);
730 $self->get_item_cvpartnumber($item);
732 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
733 my $row_as_html = $self->p->render('order/tabs/_row',
739 if ($::form->{insert_before_item_id}) {
741 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
744 ->append('#row_table_id', $row_as_html);
747 if ( $item->part->is_assortment ) {
748 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
749 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
750 my $attr = { parts_id => $assortment_item->parts_id,
751 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
752 unit => $assortment_item->unit,
753 description => $assortment_item->part->description,
755 my $item = new_item($self->order, $attr);
757 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
758 $item->discount(1) unless $assortment_item->charge;
760 $self->order->add_items( $item );
762 $self->get_item_cvpartnumber($item);
763 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
764 my $row_as_html = $self->p->render('order/tabs/_row',
769 if ($::form->{insert_before_item_id}) {
771 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
774 ->append('#row_table_id', $row_as_html);
780 ->val('.add_item_input', '')
781 ->run('kivi.Order.init_row_handlers')
782 ->run('kivi.Order.renumber_positions')
783 ->focus('#add_item_parts_id_name');
785 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
787 $self->js_redisplay_amounts_and_taxes;
791 # add item rows for multiple items at once
792 sub action_add_multi_items {
795 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
796 return $self->js->render() unless scalar @form_attr;
799 foreach my $attr (@form_attr) {
800 my $item = new_item($self->order, $attr);
802 if ( $item->part->is_assortment ) {
803 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
804 my $attr = { parts_id => $assortment_item->parts_id,
805 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
806 unit => $assortment_item->unit,
807 description => $assortment_item->part->description,
809 my $item = new_item($self->order, $attr);
811 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
812 $item->discount(1) unless $assortment_item->charge;
817 $self->order->add_items(@items);
821 foreach my $item (@items) {
822 $self->get_item_cvpartnumber($item);
823 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
824 my $row_as_html = $self->p->render('order/tabs/_row',
830 if ($::form->{insert_before_item_id}) {
832 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
835 ->append('#row_table_id', $row_as_html);
840 ->run('kivi.Part.close_picker_dialogs')
841 ->run('kivi.Order.init_row_handlers')
842 ->run('kivi.Order.renumber_positions')
843 ->focus('#add_item_parts_id_name');
845 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
847 $self->js_redisplay_amounts_and_taxes;
851 # recalculate all linetotals, amounts and taxes and redisplay them
852 sub action_recalc_amounts_and_taxes {
857 $self->js_redisplay_line_values;
858 $self->js_redisplay_amounts_and_taxes;
862 sub action_update_exchangerate {
866 is_standard => $self->order->currency_id == $::instance_conf->get_currency_id,
867 currency_name => $self->order->currency->name,
868 exchangerate => $self->order->daily_exchangerate_as_null_number,
871 $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
874 # redisplay item rows if they are sorted by an attribute
875 sub action_reorder_items {
879 partnumber => sub { $_[0]->part->partnumber },
880 description => sub { $_[0]->description },
881 qty => sub { $_[0]->qty },
882 sellprice => sub { $_[0]->sellprice },
883 discount => sub { $_[0]->discount },
884 cvpartnumber => sub { $_[0]->{cvpartnumber} },
887 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
889 my $method = $sort_keys{$::form->{order_by}};
890 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
891 if ($::form->{sort_dir}) {
892 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
893 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
895 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
898 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
899 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
901 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
905 ->run('kivi.Order.redisplay_items', \@to_sort)
909 # show the popup to choose a price/discount source
910 sub action_price_popup {
913 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
914 my $item = $self->order->items_sorted->[$idx];
916 $self->render_price_dialog($item);
919 # load the second row for one or more items
921 # This action gets the html code for all items second rows by rendering a template for
922 # the second row and sets the html code via client js.
923 sub action_load_second_rows {
926 $self->recalc() if $self->order->is_sales; # for margin calculation
928 foreach my $item_id (@{ $::form->{item_ids} }) {
929 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
930 my $item = $self->order->items_sorted->[$idx];
932 $self->js_load_second_row($item, $item_id, 0);
935 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
940 # update description, notes and sellprice from master data
941 sub action_update_row_from_master_data {
944 foreach my $item_id (@{ $::form->{item_ids} }) {
945 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
946 my $item = $self->order->items_sorted->[$idx];
947 my $texts = get_part_texts($item->part, $self->order->language_id);
949 $item->description($texts->{description});
950 $item->longdescription($texts->{longdescription});
952 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
955 if ($item->part->is_assortment) {
956 # add assortment items with price 0, as the components carry the price
957 $price_src = $price_source->price_from_source("");
958 $price_src->price(0);
960 $price_src = $price_source->best_price
961 ? $price_source->best_price
962 : $price_source->price_from_source("");
963 $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate;
964 $price_src->price(0) if !$price_source->best_price;
968 $item->sellprice($price_src->price);
969 $item->active_price_source($price_src);
972 ->run('kivi.Order.update_sellprice', $item_id, $item->sellprice_as_number)
973 ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
974 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
975 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
977 if ($self->search_cvpartnumber) {
978 $self->get_item_cvpartnumber($item);
979 $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
984 $self->js_redisplay_line_values;
985 $self->js_redisplay_amounts_and_taxes;
990 sub js_load_second_row {
991 my ($self, $item, $item_id, $do_parse) = @_;
994 # Parse values from form (they are formated while rendering (template)).
995 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
996 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
997 foreach my $var (@{ $item->cvars_by_config }) {
998 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1000 $item->parse_custom_variable_values;
1003 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1006 ->html('#second_row_' . $item_id, $row_as_html)
1007 ->data('#second_row_' . $item_id, 'loaded', 1);
1010 sub js_redisplay_line_values {
1013 my $is_sales = $self->order->is_sales;
1015 # sales orders with margins
1020 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1021 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1022 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1023 ]} @{ $self->order->items_sorted };
1027 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1028 ]} @{ $self->order->items_sorted };
1032 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1035 sub js_redisplay_amounts_and_taxes {
1038 if (scalar @{ $self->{taxes} }) {
1039 $self->js->show('#taxincluded_row_id');
1041 $self->js->hide('#taxincluded_row_id');
1044 if ($self->order->taxincluded) {
1045 $self->js->hide('#subtotal_row_id');
1047 $self->js->show('#subtotal_row_id');
1050 if ($self->order->is_sales) {
1051 my $is_neg = $self->order->marge_total < 0;
1053 ->html('#marge_total_id', $::form->format_amount(\%::myconfig, $self->order->marge_total, 2))
1054 ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
1055 ->action_if( $is_neg, 'addClass', '#marge_total_id', 'plus0')
1056 ->action_if( $is_neg, 'addClass', '#marge_percent_id', 'plus0')
1057 ->action_if( $is_neg, 'addClass', '#marge_percent_sign_id', 'plus0')
1058 ->action_if(!$is_neg, 'removeClass', '#marge_total_id', 'plus0')
1059 ->action_if(!$is_neg, 'removeClass', '#marge_percent_id', 'plus0')
1060 ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
1064 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
1065 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
1066 ->remove('.tax_row')
1067 ->insertBefore($self->build_tax_rows, '#amount_row_id');
1070 sub js_redisplay_cvpartnumbers {
1073 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1075 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1078 ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
1081 sub js_reset_order_and_item_ids_after_save {
1085 ->val('#id', $self->order->id)
1086 ->val('#converted_from_oe_id', '')
1087 ->val('#order_' . $self->nr_key(), $self->order->number);
1090 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1091 next if !$self->order->items_sorted->[$idx]->id;
1092 next if $form_item_id !~ m{^new};
1094 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1095 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1096 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1100 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1107 sub init_valid_types {
1108 [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
1114 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1115 die "Not a valid type for order";
1118 $self->type($::form->{type});
1124 my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer'
1125 : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
1126 : die "Not a valid type for order";
1131 sub init_search_cvpartnumber {
1134 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1135 my $search_cvpartnumber;
1136 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1137 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1139 return $search_cvpartnumber;
1142 sub init_show_update_button {
1145 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1156 sub init_all_price_factors {
1157 SL::DB::Manager::PriceFactor->get_all;
1160 sub init_part_picker_classification_ids {
1162 my $attribute = 'used_for_' . ($self->type =~ m{sales} ? 'sale' : 'purchase');
1164 return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => [ $attribute => 1 ]) } ];
1170 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
1172 my $right = $right_for->{ $self->type };
1173 $right ||= 'DOES_NOT_EXIST';
1175 $::auth->assert($right);
1178 # build the selection box for contacts
1180 # Needed, if customer/vendor changed.
1181 sub build_contact_select {
1184 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1185 value_key => 'cp_id',
1186 title_key => 'full_name_dep',
1187 default => $self->order->cp_id,
1189 style => 'width: 300px',
1193 # build the selection box for shiptos
1195 # Needed, if customer/vendor changed.
1196 sub build_shipto_select {
1199 select_tag('order.shipto_id',
1200 [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
1201 value_key => 'shipto_id',
1202 title_key => 'displayable_id',
1203 default => $self->order->shipto_id,
1205 style => 'width: 300px',
1209 # build the inputs for the cusom shipto dialog
1211 # Needed, if customer/vendor changed.
1212 sub build_shipto_inputs {
1215 my $content = $self->p->render('common/_ship_to_dialog',
1216 vc_obj => $self->order->customervendor,
1217 cs_obj => $self->order->custom_shipto,
1218 cvars => $self->order->custom_shipto->cvars_by_config,
1219 id_selector => '#order_shipto_id');
1221 div_tag($content, id => 'shipto_inputs');
1224 # render the info line for business
1226 # Needed, if customer/vendor changed.
1227 sub build_business_info_row
1229 $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
1232 # build the rows for displaying taxes
1234 # Called if amounts where recalculated and redisplayed.
1235 sub build_tax_rows {
1239 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
1240 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
1242 return $rows_as_html;
1246 sub render_price_dialog {
1247 my ($self, $record_item) = @_;
1249 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1253 'kivi.io.price_chooser_dialog',
1254 t8('Available Prices'),
1255 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1260 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1261 # $self->js->show('#dialog_flash_error');
1270 return if !$::form->{id};
1272 $self->order(SL::DB::Order->new(id => $::form->{id})->load);
1274 # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
1275 # You need a custom shipto object to call cvars_by_config to get the cvars.
1276 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
1278 return $self->order;
1281 # load or create a new order object
1283 # And assign changes from the form to this object.
1284 # If the order is loaded from db, check if items are deleted in the form,
1285 # remove them form the object and collect them for removing from db on saving.
1286 # Then create/update items from form (via make_item) and add them.
1290 # add_items adds items to an order with no items for saving, but they cannot
1291 # be retrieved via items until the order is saved. Adding empty items to new
1292 # order here solves this problem.
1294 $order = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1295 $order ||= SL::DB::Order->new(orderitems => [],
1296 quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())),
1297 currency_id => $::instance_conf->get_currency_id(),);
1299 my $cv_id_method = $self->cv . '_id';
1300 if (!$::form->{id} && $::form->{$cv_id_method}) {
1301 $order->$cv_id_method($::form->{$cv_id_method});
1302 setup_order_from_cv($order);
1305 my $form_orderitems = delete $::form->{order}->{orderitems};
1306 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1308 $order->assign_attributes(%{$::form->{order}});
1310 $self->setup_custom_shipto_from_form($order, $::form);
1312 if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) {
1313 my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
1314 $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
1317 # remove deleted items
1318 $self->item_ids_to_delete([]);
1319 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1320 my $item = $order->orderitems->[$idx];
1321 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1322 splice @{$order->orderitems}, $idx, 1;
1323 push @{$self->item_ids_to_delete}, $item->id;
1329 foreach my $form_attr (@{$form_orderitems}) {
1330 my $item = make_item($order, $form_attr);
1331 $item->position($pos);
1335 $order->add_items(grep {!$_->id} @items);
1340 # create or update items from form
1342 # Make item objects from form values. For items already existing read from db.
1343 # Create a new item else. And assign attributes.
1345 my ($record, $attr) = @_;
1348 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1350 my $is_new = !$item;
1352 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1353 # they cannot be retrieved via custom_variables until the order/orderitem is
1354 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1355 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1357 $item->assign_attributes(%$attr);
1360 my $texts = get_part_texts($item->part, $record->language_id);
1361 $item->longdescription($texts->{longdescription}) if !defined $attr->{longdescription};
1362 $item->project_id($record->globalproject_id) if !defined $attr->{project_id};
1363 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number};
1371 # This is used to add one item
1373 my ($record, $attr) = @_;
1375 my $item = SL::DB::OrderItem->new;
1377 # Remove attributes where the user left or set the inputs empty.
1378 # So these attributes will be undefined and we can distinguish them
1379 # from zero later on.
1380 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1381 delete $attr->{$_} if $attr->{$_} eq '';
1384 $item->assign_attributes(%$attr);
1386 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1387 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1389 $item->unit($part->unit) if !$item->unit;
1392 if ( $part->is_assortment ) {
1393 # add assortment items with price 0, as the components carry the price
1394 $price_src = $price_source->price_from_source("");
1395 $price_src->price(0);
1396 } elsif (defined $item->sellprice) {
1397 $price_src = $price_source->price_from_source("");
1398 $price_src->price($item->sellprice);
1400 $price_src = $price_source->best_price
1401 ? $price_source->best_price
1402 : $price_source->price_from_source("");
1403 $price_src->price($::form->round_amount($price_src->price / $record->exchangerate, 5)) if $record->exchangerate;
1404 $price_src->price(0) if !$price_source->best_price;
1408 if (defined $item->discount) {
1409 $discount_src = $price_source->discount_from_source("");
1410 $discount_src->discount($item->discount);
1412 $discount_src = $price_source->best_discount
1413 ? $price_source->best_discount
1414 : $price_source->discount_from_source("");
1415 $discount_src->discount(0) if !$price_source->best_discount;
1419 $new_attr{part} = $part;
1420 $new_attr{description} = $part->description if ! $item->description;
1421 $new_attr{qty} = 1.0 if ! $item->qty;
1422 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1423 $new_attr{sellprice} = $price_src->price;
1424 $new_attr{discount} = $discount_src->discount;
1425 $new_attr{active_price_source} = $price_src;
1426 $new_attr{active_discount_source} = $discount_src;
1427 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1428 $new_attr{project_id} = $record->globalproject_id;
1429 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1431 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1432 # they cannot be retrieved via custom_variables until the order/orderitem is
1433 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1434 $new_attr{custom_variables} = [];
1436 my $texts = get_part_texts($part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription});
1438 $item->assign_attributes(%new_attr, %{ $texts });
1443 sub setup_order_from_cv {
1446 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id));
1448 $order->intnotes($order->customervendor->notes);
1450 if ($order->is_sales) {
1451 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1452 $order->taxincluded(defined($order->customer->taxincluded_checked)
1453 ? $order->customer->taxincluded_checked
1454 : $::myconfig{taxincluded_checked});
1459 # setup custom shipto from form
1461 # The dialog returns form variables starting with 'shipto' and cvars starting
1462 # with 'shiptocvar_'.
1463 # Mark it to be deleted if a shipto from master data is selected
1464 # (i.e. order has a shipto).
1465 # Else, update or create a new custom shipto. If the fields are empty, it
1466 # will not be saved on save.
1467 sub setup_custom_shipto_from_form {
1468 my ($self, $order, $form) = @_;
1470 if ($order->shipto) {
1471 $self->is_custom_shipto_to_delete(1);
1473 my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1475 my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
1476 my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
1478 $custom_shipto->assign_attributes(%$shipto_attrs);
1479 $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
1483 # recalculate prices and taxes
1485 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1489 my %pat = $self->order->calculate_prices_and_taxes();
1491 $self->{taxes} = [];
1492 foreach my $tax_id (keys %{ $pat{taxes_by_tax_id} }) {
1493 my $netamount = sum0 map { $pat{amounts}->{$_}->{amount} } grep { $pat{amounts}->{$_}->{tax_id} == $tax_id } keys %{ $pat{amounts} };
1495 push(@{ $self->{taxes} }, { amount => $pat{taxes_by_tax_id}->{$tax_id},
1496 netamount => $netamount,
1497 tax => SL::DB::Tax->new(id => $tax_id)->load });
1499 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items_sorted}, @{$pat{items}};
1502 # get data for saving, printing, ..., that is not changed in the form
1504 # Only cvars for now.
1505 sub get_unalterable_data {
1508 foreach my $item (@{ $self->order->items }) {
1509 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1510 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1511 foreach my $var (@{ $item->cvars_by_config }) {
1512 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1514 $item->parse_custom_variable_values;
1520 # And remove related files in the spool directory
1525 my $db = $self->order->db;
1527 $db->with_transaction(
1529 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1530 $self->order->delete;
1531 my $spool = $::lx_office_conf{paths}->{spool};
1532 unlink map { "$spool/$_" } @spoolfiles if $spool;
1534 $self->save_history('DELETED');
1537 }) || push(@{$errors}, $db->error);
1544 # And delete items that are deleted in the form.
1549 my $db = $self->order->db;
1551 $db->with_transaction(sub {
1552 # delete custom shipto if it is to be deleted or if it is empty
1553 if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
1554 $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
1555 $self->order->custom_shipto(undef);
1558 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1559 $self->order->save(cascade => 1);
1562 if ($::form->{converted_from_oe_id}) {
1563 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1564 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1565 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1566 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
1567 $src->link_to_record($self->order);
1569 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1571 foreach (@{ $self->order->items_sorted }) {
1572 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1574 SL::DB::RecordLink->new(from_table => 'orderitems',
1575 from_id => $from_id,
1576 to_table => 'orderitems',
1584 $self->save_history('SAVED');
1587 }) || push(@{$errors}, $db->error);
1592 sub workflow_sales_or_request_for_quotation {
1596 my $errors = $self->save();
1598 if (scalar @{ $errors }) {
1599 $self->js->flash('error', $_) for @{ $errors };
1600 return $self->js->render();
1603 my $destination_type = $::form->{type} eq sales_order_type() ? sales_quotation_type() : request_quotation_type();
1605 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1606 $self->{converted_from_oe_id} = delete $::form->{id};
1608 # set item ids to new fake id, to identify them as new items
1609 foreach my $item (@{$self->order->items_sorted}) {
1610 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1614 $::form->{type} = $destination_type;
1615 $self->type($self->init_type);
1616 $self->cv ($self->init_cv);
1620 $self->get_unalterable_data();
1621 $self->pre_render();
1623 # trigger rendering values for second row as hidden, because they
1624 # are loaded only on demand. So we need to keep the values from the
1626 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1630 title => $self->get_title_for('edit'),
1631 %{$self->{template_args}}
1635 sub workflow_sales_or_purchase_order {
1639 my $errors = $self->save();
1641 if (scalar @{ $errors }) {
1642 $self->js->flash('error', $_) foreach @{ $errors };
1643 return $self->js->render();
1646 my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type()
1647 : $::form->{type} eq request_quotation_type() ? purchase_order_type()
1648 : $::form->{type} eq purchase_order_type() ? sales_order_type()
1649 : $::form->{type} eq sales_order_type() ? purchase_order_type()
1652 # check for direct delivery
1653 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
1655 if ( $::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()
1656 && $::form->{use_shipto} && $self->order->shipto) {
1657 $custom_shipto = $self->order->shipto->clone('SL::DB::Order');
1660 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1661 $self->{converted_from_oe_id} = delete $::form->{id};
1663 # set item ids to new fake id, to identify them as new items
1664 foreach my $item (@{$self->order->items_sorted}) {
1665 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1668 if ($::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()) {
1669 if ($::form->{use_shipto}) {
1670 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
1672 # remove any custom shipto if not wanted
1673 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1678 $::form->{type} = $destination_type;
1679 $self->type($self->init_type);
1680 $self->cv ($self->init_cv);
1684 $self->get_unalterable_data();
1685 $self->pre_render();
1687 # trigger rendering values for second row as hidden, because they
1688 # are loaded only on demand. So we need to keep the values from the
1690 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1694 title => $self->get_title_for('edit'),
1695 %{$self->{template_args}}
1703 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1704 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
1705 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1706 $self->{all_languages} = SL::DB::Manager::Language->get_all_sorted();
1707 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1710 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1713 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1715 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1716 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1717 $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
1718 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1719 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1721 my $print_form = Form->new('');
1722 $print_form->{type} = $self->type;
1723 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1724 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1725 form => $print_form,
1726 options => {dialog_name_prefix => 'print_options.',
1730 no_opendocument => 0,
1734 foreach my $item (@{$self->order->orderitems}) {
1735 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1736 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1737 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1740 if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
1741 # calculate shipped qtys here to prevent calling calculate for every item via the items method
1742 SL::Helper::ShippedQty->new->calculate($self->order)->write_to_objects;
1745 if ($self->order->number && $::instance_conf->get_webdav) {
1746 my $webdav = SL::Webdav->new(
1747 type => $self->type,
1748 number => $self->order->number,
1750 my @all_objects = $webdav->get_all_objects;
1751 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1753 link => File::Spec->catfile($_->full_filedescriptor),
1757 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1759 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
1760 edit_periodic_invoices_config calculate_qty kivi.Validator follow_up show_history);
1761 $self->setup_edit_action_bar;
1764 sub setup_edit_action_bar {
1765 my ($self, %params) = @_;
1767 my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
1768 || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
1769 || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
1771 for my $bar ($::request->layout->get('actionbar')) {
1776 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1777 $::instance_conf->get_order_warn_no_deliverydate,
1779 checks => [ 'kivi.Order.check_save_active_periodic_invoices', ['kivi.validate_form','#order_form'] ],
1783 call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1784 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1785 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1787 ], # end of combobox "Save"
1794 t8('Save and Quotation'),
1795 submit => [ '#order_form', { action => "Order/sales_quotation" } ],
1796 only_if => (any { $self->type eq $_ } (sales_order_type())),
1800 submit => [ '#order_form', { action => "Order/request_for_quotation" } ],
1801 only_if => (any { $self->type eq $_ } (purchase_order_type())),
1804 t8('Save and Sales Order'),
1805 submit => [ '#order_form', { action => "Order/sales_order" } ],
1806 only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
1809 t8('Save and Purchase Order'),
1810 call => [ 'kivi.Order.purchase_order_check_for_direct_delivery' ],
1811 only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
1814 t8('Save and Delivery Order'),
1815 call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1816 $::instance_conf->get_order_warn_no_deliverydate,
1818 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1819 only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type()))
1822 t8('Save and Invoice'),
1823 call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1824 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1827 t8('Save and AP Transaction'),
1828 call => [ 'kivi.Order.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
1829 only_if => (any { $self->type eq $_ } (purchase_order_type()))
1832 ], # end of combobox "Workflow"
1839 t8('Save and print'),
1840 call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts ],
1843 t8('Save and E-mail'),
1844 id => 'save_and_email_action',
1845 call => [ 'kivi.Order.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts ],
1846 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1849 t8('Download attachments of all parts'),
1850 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1851 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1852 only_if => $::instance_conf->get_doc_storage,
1854 ], # end of combobox "Export"
1858 call => [ 'kivi.Order.delete_order' ],
1859 confirm => $::locale->text('Do you really want to delete this object?'),
1860 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1861 only_if => $deletion_allowed,
1870 call => [ 'kivi.Order.follow_up_window' ],
1871 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1872 only_if => $::auth->assert('productivity', 1),
1876 call => [ 'set_history_window', $self->order->id, 'id' ],
1877 disabled => !$self->order->id ? t8('This record has not been saved yet.') : undef,
1879 ], # end of combobox "more"
1885 my ($order, $pdf_ref, $params) = @_;
1889 my $print_form = Form->new('');
1890 $print_form->{type} = $order->type;
1891 $print_form->{formname} = $params->{formname} || $order->type;
1892 $print_form->{format} = $params->{format} || 'pdf';
1893 $print_form->{media} = $params->{media} || 'file';
1894 $print_form->{groupitems} = $params->{groupitems};
1895 $print_form->{printer_id} = $params->{printer_id};
1896 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1898 $order->language($params->{language});
1899 $order->flatten_to_form($print_form, format_amounts => 1);
1903 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
1904 $template_ext = 'odt';
1905 $template_type = 'OpenDocument';
1908 # search for the template
1909 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1910 name => $print_form->{formname},
1911 extension => $template_ext,
1912 email => $print_form->{media} eq 'email',
1913 language => $params->{language},
1914 printer_id => $print_form->{printer_id},
1917 if (!defined $template_file) {
1918 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);
1921 return @errors if scalar @errors;
1923 $print_form->throw_on_error(sub {
1925 $print_form->prepare_for_printing;
1927 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1928 format => $print_form->{format},
1929 template_type => $template_type,
1930 template => $template_file,
1931 variables => $print_form,
1932 variable_content_types => {
1933 longdescription => 'html',
1934 partnotes => 'html',
1939 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
1945 sub get_files_for_email_dialog {
1948 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
1950 return %files if !$::instance_conf->get_doc_storage;
1952 if ($self->order->id) {
1953 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
1954 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
1955 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
1959 uniq_by { $_->{id} }
1961 +{ id => $_->part->id,
1962 partnumber => $_->part->partnumber }
1963 } @{$self->order->items_sorted};
1965 foreach my $part (@parts) {
1966 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
1967 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
1970 foreach my $key (keys %files) {
1971 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
1977 sub make_periodic_invoices_config_from_yaml {
1978 my ($yaml_config) = @_;
1980 return if !$yaml_config;
1981 my $attr = SL::YAML::Load($yaml_config);
1982 return if 'HASH' ne ref $attr;
1983 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
1987 sub get_periodic_invoices_status {
1988 my ($self, $config) = @_;
1990 return if $self->type ne sales_order_type();
1991 return t8('not configured') if !$config;
1993 my $active = ('HASH' eq ref $config) ? $config->{active}
1994 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
1995 : die "Cannot get status of periodic invoices config";
1997 return $active ? t8('active') : t8('inactive');
2001 my ($self, $action) = @_;
2003 return '' if none { lc($action)} qw(add edit);
2006 # $::locale->text("Add Sales Order");
2007 # $::locale->text("Add Purchase Order");
2008 # $::locale->text("Add Quotation");
2009 # $::locale->text("Add Request for Quotation");
2010 # $::locale->text("Edit Sales Order");
2011 # $::locale->text("Edit Purchase Order");
2012 # $::locale->text("Edit Quotation");
2013 # $::locale->text("Edit Request for Quotation");
2015 $action = ucfirst(lc($action));
2016 return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order")
2017 : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order")
2018 : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation")
2019 : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
2023 sub get_item_cvpartnumber {
2024 my ($self, $item) = @_;
2026 return if !$self->search_cvpartnumber;
2027 return if !$self->order->customervendor;
2029 if ($self->cv eq 'vendor') {
2030 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
2031 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
2032 } elsif ($self->cv eq 'customer') {
2033 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
2034 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
2038 sub get_part_texts {
2039 my ($part_or_id, $language_or_id, %defaults) = @_;
2041 my $part = ref($part_or_id) ? $part_or_id : SL::DB::Part->load_cached($part_or_id);
2042 my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id;
2044 description => $defaults{description} // $part->description,
2045 longdescription => $defaults{longdescription} // $part->notes,
2048 return $texts unless $language_id;
2050 my $translation = SL::DB::Manager::Translation->get_first(
2052 parts_id => $part->id,
2053 language_id => $language_id,
2056 $texts->{description} = $translation->translation if $translation && $translation->translation;
2057 $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription;
2062 sub sales_order_type {
2066 sub purchase_order_type {
2070 sub sales_quotation_type {
2074 sub request_quotation_type {
2075 'request_quotation';
2079 return $_[0]->type eq sales_order_type() ? 'ordnumber'
2080 : $_[0]->type eq purchase_order_type() ? 'ordnumber'
2081 : $_[0]->type eq sales_quotation_type() ? 'quonumber'
2082 : $_[0]->type eq request_quotation_type() ? 'quonumber'
2086 sub save_and_redirect_to {
2087 my ($self, %params) = @_;
2089 my $errors = $self->save();
2091 if (scalar @{ $errors }) {
2092 $self->js->flash('error', $_) foreach @{ $errors };
2093 return $self->js->render();
2096 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
2097 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
2098 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
2099 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
2101 flash_later('info', $text);
2103 $self->redirect_to(%params, id => $self->order->id);
2107 my ($self, $addition) = @_;
2109 my $number_type = $self->order->type =~ m{order} ? 'ordnumber' : 'quonumber';
2110 my $snumbers = $number_type . '_' . $self->order->$number_type;
2112 SL::DB::History->new(
2113 trans_id => $self->order->id,
2114 employee_id => SL::DB::Manager::Employee->current->id,
2115 what_done => $self->order->type,
2116 snumbers => $snumbers,
2117 addition => $addition,
2121 sub store_pdf_to_webdav_and_filemanegement {
2122 my($order, $content, $filename) = @_;
2126 # copy file to webdav folder
2127 if ($order->number && $::instance_conf->get_webdav_documents) {
2128 my $webdav = SL::Webdav->new(
2129 type => $order->type,
2130 number => $order->number,
2132 my $webdav_file = SL::Webdav::File->new(
2134 filename => $filename,
2137 $webdav_file->store(data => \$content);
2140 push @errors, t8('Storing PDF to webdav folder failed: #1', $@);
2143 if ($order->id && $::instance_conf->get_doc_storage) {
2145 SL::File->save(object_id => $order->id,
2146 object_type => $order->type,
2147 mime_type => 'application/pdf',
2148 source => 'created',
2149 file_type => 'document',
2150 file_name => $filename,
2151 file_contents => $content);
2154 push @errors, t8('Storing PDF in storage backend failed: #1', $@);
2169 SL::Controller::Order - controller for orders
2173 This is a new form to enter orders, completely rewritten with the use
2174 of controller and java script techniques.
2176 The aim is to provide the user a better experience and a faster workflow. Also
2177 the code should be more readable, more reliable and better to maintain.
2185 One input row, so that input happens every time at the same place.
2189 Use of pickers where possible.
2193 Possibility to enter more than one item at once.
2197 Item list in a scrollable area, so that the workflow buttons stay at
2202 Reordering item rows with drag and drop is possible. Sorting item rows is
2203 possible (by partnumber, description, qty, sellprice and discount for now).
2207 No C<update> is necessary. All entries and calculations are managed
2208 with ajax-calls and the page only reloads on C<save>.
2212 User can see changes immediately, because of the use of java script
2223 =item * C<SL/Controller/Order.pm>
2227 =item * C<template/webpages/order/form.html>
2231 =item * C<template/webpages/order/tabs/basic_data.html>
2233 Main tab for basic_data.
2235 This is the only tab here for now. "linked records" and "webdav" tabs are
2236 reused from generic code.
2240 =item * C<template/webpages/order/tabs/_business_info_row.html>
2242 For displaying information on business type
2244 =item * C<template/webpages/order/tabs/_item_input.html>
2246 The input line for items
2248 =item * C<template/webpages/order/tabs/_row.html>
2250 One row for already entered items
2252 =item * C<template/webpages/order/tabs/_tax_row.html>
2254 Displaying tax information
2256 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
2258 Dialog for selecting price and discount sources
2262 =item * C<js/kivi.Order.js>
2264 java script functions
2274 =item * price sources: little symbols showing better price / better discount
2276 =item * select units in input row?
2278 =item * check for direct delivery (workflow sales order -> purchase order)
2280 =item * access rights
2282 =item * display weights
2286 =item * optional client/user behaviour
2288 (transactions has to be set - department has to be set -
2289 force project if enabled in client config - transport cost reminder)
2293 =head1 KNOWN BUGS AND CAVEATS
2299 Customer discount is not displayed as a valid discount in price source popup
2300 (this might be a bug in price sources)
2302 (I cannot reproduce this (Bernd))
2306 No indication that <shift>-up/down expands/collapses second row.
2310 Inline creation of parts is not currently supported
2314 Table header is not sticky in the scrolling area.
2318 Sorting does not include C<position>, neither does reordering.
2320 This behavior was implemented intentionally. But we can discuss, which behavior
2321 should be implemented.
2325 =head1 To discuss / Nice to have
2331 How to expand/collapse second row. Now it can be done clicking the icon or
2336 Possibility to select PriceSources in input row?
2340 This controller uses a (changed) copy of the template for the PriceSource
2341 dialog. Maybe there could be used one code source.
2345 Rounding-differences between this controller (PriceTaxCalculator) and the old
2346 form. This is not only a problem here, but also in all parts using the PTC.
2347 There exists a ticket and a patch. This patch should be testet.
2351 An indicator, if the actual inputs are saved (like in an
2352 editor or on text processing application).
2356 A warning when leaving the page without saveing unchanged inputs.
2363 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>