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_type() ? $::instance_conf->get_reqdate_interval :
73 $self->type eq sales_order_type() ? $::instance_conf->get_delivery_date_interval : 1;
75 if ( ($self->type eq sales_order_type() && $::instance_conf->get_deliverydate_on)
76 || ($self->type eq sales_quotation_type() && $::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_type() ? $::instance_conf->get_reqdate_interval :
231 $self->type eq sales_order_type() ? $::instance_conf->get_delivery_date_interval : 1;
233 if ( ($self->type eq sales_order_type() && !$::instance_conf->get_deliverydate_on)
234 || ($self->type eq sales_quotation_type() && !$::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')
341 sub action_preview_pdf {
344 my $errors = $self->save();
345 if (scalar @{ $errors }) {
346 $self->js->flash('error', $_) foreach @{ $errors };
347 return $self->js->render();
350 $self->js_reset_order_and_item_ids_after_save;
353 my $media = 'screen';
354 my $formname = $self->type;
357 # create a form for generate_attachment_filename
358 my $form = Form->new;
359 $form->{$self->nr_key()} = $self->order->number;
360 $form->{type} = $self->type;
361 $form->{format} = $format;
362 $form->{formname} = $formname;
363 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
364 my $pdf_filename = $form->generate_attachment_filename();
367 my @errors = generate_pdf($self->order, \$pdf, { format => $format,
368 formname => $formname,
369 language => $self->order->language,
371 if (scalar @errors) {
372 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
374 $self->save_history('PREVIEWED');
375 $self->js->flash('info', t8('The PDF has been previewed'));
379 type => SL::MIME->mime_type_from_ext($pdf_filename),
380 name => $pdf_filename,
385 # open the email dialog
386 sub action_save_and_show_email_dialog {
389 my $errors = $self->save();
391 if (scalar @{ $errors }) {
392 $self->js->flash('error', $_) foreach @{ $errors };
393 return $self->js->render();
396 my $cv_method = $self->cv;
398 if (!$self->order->$cv_method) {
399 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'))
404 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
405 $email_form->{to} ||= $self->order->$cv_method->email;
406 $email_form->{cc} = $self->order->$cv_method->cc;
407 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
408 # Todo: get addresses from shipto, if any
410 my $form = Form->new;
411 $form->{$self->nr_key()} = $self->order->number;
412 $form->{cusordnumber} = $self->order->cusordnumber;
413 $form->{formname} = $self->type;
414 $form->{type} = $self->type;
415 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
416 $form->{language_id} = $self->order->language->id if $self->order->language;
417 $form->{format} = 'pdf';
418 $form->{cp_id} = $self->order->contact->cp_id if $self->order->contact;
420 $email_form->{subject} = $form->generate_email_subject();
421 $email_form->{attachment_filename} = $form->generate_attachment_filename();
422 $email_form->{message} = $form->generate_email_body();
423 $email_form->{js_send_function} = 'kivi.Order.send_email()';
425 my %files = $self->get_files_for_email_dialog();
426 $self->{all_employees} = SL::DB::Manager::Employee->get_all(query => [ deleted => 0 ]);
427 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
428 email_form => $email_form,
429 show_bcc => $::auth->assert('email_bcc', 'may fail'),
431 is_customer => $self->cv eq 'customer',
432 ALL_EMPLOYEES => $self->{all_employees},
436 ->run('kivi.Order.show_email_dialog', $dialog_html)
443 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
444 sub action_send_email {
447 my $errors = $self->save();
449 if (scalar @{ $errors }) {
450 $self->js->run('kivi.Order.close_email_dialog');
451 $self->js->flash('error', $_) foreach @{ $errors };
452 return $self->js->render();
455 $self->js_reset_order_and_item_ids_after_save;
457 my $email_form = delete $::form->{email_form};
458 my %field_names = (to => 'email');
460 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
462 # for Form::cleanup which may be called in Form::send_email
463 $::form->{cwd} = getcwd();
464 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
466 $::form->{$_} = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
467 $::form->{media} = 'email';
469 if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) {
471 my @errors = generate_pdf($self->order, \$pdf, {media => $::form->{media},
472 format => $::form->{print_options}->{format},
473 formname => $::form->{print_options}->{formname},
474 language => $self->order->language,
475 printer_id => $::form->{print_options}->{printer_id},
476 groupitems => $::form->{print_options}->{groupitems}});
477 if (scalar @errors) {
478 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
481 my @warnings = store_pdf_to_webdav_and_filemanagement($self->order, $pdf, $::form->{attachment_filename});
482 if (scalar @warnings) {
483 flash_later('warning', $_) for @warnings;
486 my $sfile = SL::SessionFile::Random->new(mode => "w");
487 $sfile->fh->print($pdf);
490 $::form->{tmpfile} = $sfile->file_name;
491 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
494 $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a linked record to the mail
495 $::form->send_email(\%::myconfig, 'pdf');
498 my $intnotes = $self->order->intnotes;
499 $intnotes .= "\n\n" if $self->order->intnotes;
500 $intnotes .= t8('[email]') . "\n";
501 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
502 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
503 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
504 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
505 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
506 $intnotes .= t8('Message') . ": " . $::form->{message};
508 $self->order->update_attributes(intnotes => $intnotes);
510 $self->save_history('MAILED');
512 flash_later('info', t8('The email has been sent.'));
514 my @redirect_params = (
517 id => $self->order->id,
520 $self->redirect_to(@redirect_params);
523 # open the periodic invoices config dialog
525 # If there are values in the form (i.e. dialog was opened before),
526 # then use this values. Create new ones, else.
527 sub action_show_periodic_invoices_config_dialog {
530 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
531 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
532 $config ||= SL::DB::PeriodicInvoicesConfig->new(periodicity => 'm',
533 order_value_periodicity => 'p', # = same as periodicity
534 start_date_as_date => $::form->{transdate_as_date} || $::form->current_date,
535 extend_automatically_by => 12,
537 email_subject => GenericTranslations->get(
538 language_id => $::form->{language_id},
539 translation_type =>"preset_text_periodic_invoices_email_subject"),
540 email_body => GenericTranslations->get(
541 language_id => $::form->{language_id},
542 translation_type =>"preset_text_periodic_invoices_email_body"),
544 $config->periodicity('m') if none { $_ eq $config->periodicity } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
545 $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
547 $::form->get_lists(printers => "ALL_PRINTERS",
548 charts => { key => 'ALL_CHARTS',
549 transdate => 'current_date' });
551 $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
553 if ($::form->{customer_id}) {
554 $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
555 $::form->{email_recipient_invoice_address} = SL::DB::Manager::Customer->find_by(id => $::form->{customer_id})->invoice_mail;
558 $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
560 popup_js_close_function => 'kivi.Order.close_periodic_invoices_config_dialog()',
561 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
566 # assign the values of the periodic invoices config dialog
567 # as yaml in the hidden tag and set the status.
568 sub action_assign_periodic_invoices_config {
571 $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
573 my $config = { active => $::form->{active} ? 1 : 0,
574 terminated => $::form->{terminated} ? 1 : 0,
575 direct_debit => $::form->{direct_debit} ? 1 : 0,
576 periodicity => (any { $_ eq $::form->{periodicity} } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES) ? $::form->{periodicity} : 'm',
577 order_value_periodicity => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
578 start_date_as_date => $::form->{start_date_as_date},
579 end_date_as_date => $::form->{end_date_as_date},
580 first_billing_date_as_date => $::form->{first_billing_date_as_date},
581 print => $::form->{print} ? 1 : 0,
582 printer_id => $::form->{print} ? $::form->{printer_id} * 1 : undef,
583 copies => $::form->{copies} * 1 ? $::form->{copies} : 1,
584 extend_automatically_by => $::form->{extend_automatically_by} * 1 || undef,
585 ar_chart_id => $::form->{ar_chart_id} * 1,
586 send_email => $::form->{send_email} ? 1 : 0,
587 email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
588 email_recipient_address => $::form->{email_recipient_address},
589 email_sender => $::form->{email_sender},
590 email_subject => $::form->{email_subject},
591 email_body => $::form->{email_body},
594 my $periodic_invoices_config = SL::YAML::Dump($config);
596 my $status = $self->get_periodic_invoices_status($config);
599 ->remove('#order_periodic_invoices_config')
600 ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
601 ->run('kivi.Order.close_periodic_invoices_config_dialog')
602 ->html('#periodic_invoices_status', $status)
603 ->flash('info', t8('The periodic invoices config has been assigned.'))
607 sub action_get_has_active_periodic_invoices {
610 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
611 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
613 my $has_active_periodic_invoices =
614 $self->type eq sales_order_type()
617 && (!$config->end_date || ($config->end_date > DateTime->today_local))
618 && $config->get_previous_billed_period_start_date;
620 $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
623 # save the order and redirect to the frontend subroutine for a new
625 sub action_save_and_delivery_order {
628 $self->save_and_redirect_to(
629 controller => 'oe.pl',
630 action => 'oe_delivery_order_from_order',
634 # save the order and redirect to the frontend subroutine for a new
636 sub action_save_and_invoice {
639 $self->save_and_redirect_to(
640 controller => 'oe.pl',
641 action => 'oe_invoice_from_order',
645 # workflow from sales order to sales quotation
646 sub action_sales_quotation {
647 $_[0]->workflow_sales_or_request_for_quotation();
650 # workflow from sales order to sales quotation
651 sub action_request_for_quotation {
652 $_[0]->workflow_sales_or_request_for_quotation();
655 # workflow from sales quotation to sales order
656 sub action_sales_order {
657 $_[0]->workflow_sales_or_purchase_order();
660 # workflow from rfq to purchase order
661 sub action_purchase_order {
662 $_[0]->workflow_sales_or_purchase_order();
665 # workflow from purchase order to ap transaction
666 sub action_save_and_ap_transaction {
669 $self->save_and_redirect_to(
670 controller => 'ap.pl',
671 action => 'add_from_purchase_order',
675 # set form elements in respect to a changed customer or vendor
677 # This action is called on an change of the customer/vendor picker.
678 sub action_customer_vendor_changed {
681 setup_order_from_cv($self->order);
684 my $cv_method = $self->cv;
686 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
687 $self->js->show('#cp_row');
689 $self->js->hide('#cp_row');
692 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
693 $self->js->show('#shipto_selection');
695 $self->js->hide('#shipto_selection');
698 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
701 ->replaceWith('#order_cp_id', $self->build_contact_select)
702 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
703 ->replaceWith('#shipto_inputs ', $self->build_shipto_inputs)
704 ->replaceWith('#business_info_row', $self->build_business_info_row)
705 ->val( '#order_taxzone_id', $self->order->taxzone_id)
706 ->val( '#order_taxincluded', $self->order->taxincluded)
707 ->val( '#order_currency_id', $self->order->currency_id)
708 ->val( '#order_payment_id', $self->order->payment_id)
709 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
710 ->val( '#order_intnotes', $self->order->intnotes)
711 ->val( '#order_language_id', $self->order->$cv_method->language_id)
712 ->focus( '#order_' . $self->cv . '_id')
713 ->run('kivi.Order.update_exchangerate');
715 $self->js_redisplay_amounts_and_taxes;
716 $self->js_redisplay_cvpartnumbers;
720 # open the dialog for customer/vendor details
721 sub action_show_customer_vendor_details_dialog {
724 my $is_customer = 'customer' eq $::form->{vc};
727 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
729 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
732 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
733 $details{discount_as_percent} = $cv->discount_as_percent;
734 $details{creditlimt} = $cv->creditlimit_as_number;
735 $details{business} = $cv->business->description if $cv->business;
736 $details{language} = $cv->language_obj->description if $cv->language_obj;
737 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
738 $details{payment_terms} = $cv->payment->description if $cv->payment;
739 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
741 foreach my $entry (@{ $cv->shipto }) {
742 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
744 foreach my $entry (@{ $cv->contacts }) {
745 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
748 $_[0]->render('common/show_vc_details', { layout => 0 },
749 is_customer => $is_customer,
754 # called if a unit in an existing item row is changed
755 sub action_unit_changed {
758 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
759 my $item = $self->order->items_sorted->[$idx];
761 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
762 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
767 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
768 $self->js_redisplay_line_values;
769 $self->js_redisplay_amounts_and_taxes;
773 # add an item row for a new item entered in the input row
774 sub action_add_item {
777 my $form_attr = $::form->{add_item};
779 return unless $form_attr->{parts_id};
781 my $item = new_item($self->order, $form_attr);
783 $self->order->add_items($item);
787 $self->get_item_cvpartnumber($item);
789 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
790 my $row_as_html = $self->p->render('order/tabs/_row',
796 if ($::form->{insert_before_item_id}) {
798 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
801 ->append('#row_table_id', $row_as_html);
804 if ( $item->part->is_assortment ) {
805 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
806 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
807 my $attr = { parts_id => $assortment_item->parts_id,
808 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
809 unit => $assortment_item->unit,
810 description => $assortment_item->part->description,
812 my $item = new_item($self->order, $attr);
814 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
815 $item->discount(1) unless $assortment_item->charge;
817 $self->order->add_items( $item );
819 $self->get_item_cvpartnumber($item);
820 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
821 my $row_as_html = $self->p->render('order/tabs/_row',
826 if ($::form->{insert_before_item_id}) {
828 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
831 ->append('#row_table_id', $row_as_html);
837 ->val('.add_item_input', '')
838 ->run('kivi.Order.init_row_handlers')
839 ->run('kivi.Order.renumber_positions')
840 ->focus('#add_item_parts_id_name');
842 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
844 $self->js_redisplay_amounts_and_taxes;
848 # add item rows for multiple items at once
849 sub action_add_multi_items {
852 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
853 return $self->js->render() unless scalar @form_attr;
856 foreach my $attr (@form_attr) {
857 my $item = new_item($self->order, $attr);
859 if ( $item->part->is_assortment ) {
860 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
861 my $attr = { parts_id => $assortment_item->parts_id,
862 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
863 unit => $assortment_item->unit,
864 description => $assortment_item->part->description,
866 my $item = new_item($self->order, $attr);
868 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
869 $item->discount(1) unless $assortment_item->charge;
874 $self->order->add_items(@items);
878 foreach my $item (@items) {
879 $self->get_item_cvpartnumber($item);
880 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
881 my $row_as_html = $self->p->render('order/tabs/_row',
887 if ($::form->{insert_before_item_id}) {
889 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
892 ->append('#row_table_id', $row_as_html);
897 ->run('kivi.Part.close_picker_dialogs')
898 ->run('kivi.Order.init_row_handlers')
899 ->run('kivi.Order.renumber_positions')
900 ->focus('#add_item_parts_id_name');
902 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
904 $self->js_redisplay_amounts_and_taxes;
908 # recalculate all linetotals, amounts and taxes and redisplay them
909 sub action_recalc_amounts_and_taxes {
914 $self->js_redisplay_line_values;
915 $self->js_redisplay_amounts_and_taxes;
919 sub action_update_exchangerate {
923 is_standard => $self->order->currency_id == $::instance_conf->get_currency_id,
924 currency_name => $self->order->currency->name,
925 exchangerate => $self->order->daily_exchangerate_as_null_number,
928 $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
931 # redisplay item rows if they are sorted by an attribute
932 sub action_reorder_items {
936 partnumber => sub { $_[0]->part->partnumber },
937 description => sub { $_[0]->description },
938 qty => sub { $_[0]->qty },
939 sellprice => sub { $_[0]->sellprice },
940 discount => sub { $_[0]->discount },
941 cvpartnumber => sub { $_[0]->{cvpartnumber} },
944 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
946 my $method = $sort_keys{$::form->{order_by}};
947 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
948 if ($::form->{sort_dir}) {
949 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
950 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
952 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
955 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
956 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
958 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
962 ->run('kivi.Order.redisplay_items', \@to_sort)
966 # show the popup to choose a price/discount source
967 sub action_price_popup {
970 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
971 my $item = $self->order->items_sorted->[$idx];
973 $self->render_price_dialog($item);
976 # load the second row for one or more items
978 # This action gets the html code for all items second rows by rendering a template for
979 # the second row and sets the html code via client js.
980 sub action_load_second_rows {
983 $self->recalc() if $self->order->is_sales; # for margin calculation
985 foreach my $item_id (@{ $::form->{item_ids} }) {
986 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
987 my $item = $self->order->items_sorted->[$idx];
989 $self->js_load_second_row($item, $item_id, 0);
992 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
997 # update description, notes and sellprice from master data
998 sub action_update_row_from_master_data {
1001 foreach my $item_id (@{ $::form->{item_ids} }) {
1002 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1003 my $item = $self->order->items_sorted->[$idx];
1004 my $texts = get_part_texts($item->part, $self->order->language_id);
1006 $item->description($texts->{description});
1007 $item->longdescription($texts->{longdescription});
1009 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1012 if ($item->part->is_assortment) {
1013 # add assortment items with price 0, as the components carry the price
1014 $price_src = $price_source->price_from_source("");
1015 $price_src->price(0);
1017 $price_src = $price_source->best_price
1018 ? $price_source->best_price
1019 : $price_source->price_from_source("");
1020 $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate;
1021 $price_src->price(0) if !$price_source->best_price;
1025 $item->sellprice($price_src->price);
1026 $item->active_price_source($price_src);
1029 ->run('kivi.Order.update_sellprice', $item_id, $item->sellprice_as_number)
1030 ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
1031 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
1032 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
1034 if ($self->search_cvpartnumber) {
1035 $self->get_item_cvpartnumber($item);
1036 $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
1041 $self->js_redisplay_line_values;
1042 $self->js_redisplay_amounts_and_taxes;
1044 $self->js->render();
1047 sub js_load_second_row {
1048 my ($self, $item, $item_id, $do_parse) = @_;
1051 # Parse values from form (they are formated while rendering (template)).
1052 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1053 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1054 foreach my $var (@{ $item->cvars_by_config }) {
1055 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1057 $item->parse_custom_variable_values;
1060 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1063 ->html('#second_row_' . $item_id, $row_as_html)
1064 ->data('#second_row_' . $item_id, 'loaded', 1);
1067 sub js_redisplay_line_values {
1070 my $is_sales = $self->order->is_sales;
1072 # sales orders with margins
1077 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1078 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1079 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1080 ]} @{ $self->order->items_sorted };
1084 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1085 ]} @{ $self->order->items_sorted };
1089 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1092 sub js_redisplay_amounts_and_taxes {
1095 if (scalar @{ $self->{taxes} }) {
1096 $self->js->show('#taxincluded_row_id');
1098 $self->js->hide('#taxincluded_row_id');
1101 if ($self->order->taxincluded) {
1102 $self->js->hide('#subtotal_row_id');
1104 $self->js->show('#subtotal_row_id');
1107 if ($self->order->is_sales) {
1108 my $is_neg = $self->order->marge_total < 0;
1110 ->html('#marge_total_id', $::form->format_amount(\%::myconfig, $self->order->marge_total, 2))
1111 ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
1112 ->action_if( $is_neg, 'addClass', '#marge_total_id', 'plus0')
1113 ->action_if( $is_neg, 'addClass', '#marge_percent_id', 'plus0')
1114 ->action_if( $is_neg, 'addClass', '#marge_percent_sign_id', 'plus0')
1115 ->action_if(!$is_neg, 'removeClass', '#marge_total_id', 'plus0')
1116 ->action_if(!$is_neg, 'removeClass', '#marge_percent_id', 'plus0')
1117 ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
1121 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
1122 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
1123 ->remove('.tax_row')
1124 ->insertBefore($self->build_tax_rows, '#amount_row_id');
1127 sub js_redisplay_cvpartnumbers {
1130 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1132 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1135 ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
1138 sub js_reset_order_and_item_ids_after_save {
1142 ->val('#id', $self->order->id)
1143 ->val('#converted_from_oe_id', '')
1144 ->val('#order_' . $self->nr_key(), $self->order->number);
1147 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1148 next if !$self->order->items_sorted->[$idx]->id;
1149 next if $form_item_id !~ m{^new};
1151 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1152 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1153 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1157 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1164 sub init_valid_types {
1165 [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
1171 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1172 die "Not a valid type for order";
1175 $self->type($::form->{type});
1181 my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer'
1182 : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
1183 : die "Not a valid type for order";
1188 sub init_search_cvpartnumber {
1191 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1192 my $search_cvpartnumber;
1193 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1194 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1196 return $search_cvpartnumber;
1199 sub init_show_update_button {
1202 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1213 sub init_all_price_factors {
1214 SL::DB::Manager::PriceFactor->get_all;
1217 sub init_part_picker_classification_ids {
1219 my $attribute = 'used_for_' . ($self->type =~ m{sales} ? 'sale' : 'purchase');
1221 return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => [ $attribute => 1 ]) } ];
1227 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
1229 my $right = $right_for->{ $self->type };
1230 $right ||= 'DOES_NOT_EXIST';
1232 $::auth->assert($right);
1235 # build the selection box for contacts
1237 # Needed, if customer/vendor changed.
1238 sub build_contact_select {
1241 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1242 value_key => 'cp_id',
1243 title_key => 'full_name_dep',
1244 default => $self->order->cp_id,
1246 style => 'width: 300px',
1250 # build the selection box for shiptos
1252 # Needed, if customer/vendor changed.
1253 sub build_shipto_select {
1256 select_tag('order.shipto_id',
1257 [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
1258 value_key => 'shipto_id',
1259 title_key => 'displayable_id',
1260 default => $self->order->shipto_id,
1262 style => 'width: 300px',
1266 # build the inputs for the cusom shipto dialog
1268 # Needed, if customer/vendor changed.
1269 sub build_shipto_inputs {
1272 my $content = $self->p->render('common/_ship_to_dialog',
1273 vc_obj => $self->order->customervendor,
1274 cs_obj => $self->order->custom_shipto,
1275 cvars => $self->order->custom_shipto->cvars_by_config,
1276 id_selector => '#order_shipto_id');
1278 div_tag($content, id => 'shipto_inputs');
1281 # render the info line for business
1283 # Needed, if customer/vendor changed.
1284 sub build_business_info_row
1286 $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
1289 # build the rows for displaying taxes
1291 # Called if amounts where recalculated and redisplayed.
1292 sub build_tax_rows {
1296 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
1297 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
1299 return $rows_as_html;
1303 sub render_price_dialog {
1304 my ($self, $record_item) = @_;
1306 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1310 'kivi.io.price_chooser_dialog',
1311 t8('Available Prices'),
1312 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1317 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1318 # $self->js->show('#dialog_flash_error');
1327 return if !$::form->{id};
1329 $self->order(SL::DB::Order->new(id => $::form->{id})->load);
1331 # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
1332 # You need a custom shipto object to call cvars_by_config to get the cvars.
1333 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
1335 return $self->order;
1338 # load or create a new order object
1340 # And assign changes from the form to this object.
1341 # If the order is loaded from db, check if items are deleted in the form,
1342 # remove them form the object and collect them for removing from db on saving.
1343 # Then create/update items from form (via make_item) and add them.
1347 # add_items adds items to an order with no items for saving, but they cannot
1348 # be retrieved via items until the order is saved. Adding empty items to new
1349 # order here solves this problem.
1351 $order = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1352 $order ||= SL::DB::Order->new(orderitems => [],
1353 quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())),
1354 currency_id => $::instance_conf->get_currency_id(),);
1356 my $cv_id_method = $self->cv . '_id';
1357 if (!$::form->{id} && $::form->{$cv_id_method}) {
1358 $order->$cv_id_method($::form->{$cv_id_method});
1359 setup_order_from_cv($order);
1362 my $form_orderitems = delete $::form->{order}->{orderitems};
1363 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1365 $order->assign_attributes(%{$::form->{order}});
1367 $self->setup_custom_shipto_from_form($order, $::form);
1369 if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) {
1370 my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
1371 $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
1374 # remove deleted items
1375 $self->item_ids_to_delete([]);
1376 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1377 my $item = $order->orderitems->[$idx];
1378 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1379 splice @{$order->orderitems}, $idx, 1;
1380 push @{$self->item_ids_to_delete}, $item->id;
1386 foreach my $form_attr (@{$form_orderitems}) {
1387 my $item = make_item($order, $form_attr);
1388 $item->position($pos);
1392 $order->add_items(grep {!$_->id} @items);
1397 # create or update items from form
1399 # Make item objects from form values. For items already existing read from db.
1400 # Create a new item else. And assign attributes.
1402 my ($record, $attr) = @_;
1405 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1407 my $is_new = !$item;
1409 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1410 # they cannot be retrieved via custom_variables until the order/orderitem is
1411 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1412 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1414 $item->assign_attributes(%$attr);
1417 my $texts = get_part_texts($item->part, $record->language_id);
1418 $item->longdescription($texts->{longdescription}) if !defined $attr->{longdescription};
1419 $item->project_id($record->globalproject_id) if !defined $attr->{project_id};
1420 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number};
1428 # This is used to add one item
1430 my ($record, $attr) = @_;
1432 my $item = SL::DB::OrderItem->new;
1434 # Remove attributes where the user left or set the inputs empty.
1435 # So these attributes will be undefined and we can distinguish them
1436 # from zero later on.
1437 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1438 delete $attr->{$_} if $attr->{$_} eq '';
1441 $item->assign_attributes(%$attr);
1443 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1444 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1446 $item->unit($part->unit) if !$item->unit;
1449 if ( $part->is_assortment ) {
1450 # add assortment items with price 0, as the components carry the price
1451 $price_src = $price_source->price_from_source("");
1452 $price_src->price(0);
1453 } elsif (defined $item->sellprice) {
1454 $price_src = $price_source->price_from_source("");
1455 $price_src->price($item->sellprice);
1457 $price_src = $price_source->best_price
1458 ? $price_source->best_price
1459 : $price_source->price_from_source("");
1460 $price_src->price($::form->round_amount($price_src->price / $record->exchangerate, 5)) if $record->exchangerate;
1461 $price_src->price(0) if !$price_source->best_price;
1465 if (defined $item->discount) {
1466 $discount_src = $price_source->discount_from_source("");
1467 $discount_src->discount($item->discount);
1469 $discount_src = $price_source->best_discount
1470 ? $price_source->best_discount
1471 : $price_source->discount_from_source("");
1472 $discount_src->discount(0) if !$price_source->best_discount;
1476 $new_attr{part} = $part;
1477 $new_attr{description} = $part->description if ! $item->description;
1478 $new_attr{qty} = 1.0 if ! $item->qty;
1479 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1480 $new_attr{sellprice} = $price_src->price;
1481 $new_attr{discount} = $discount_src->discount;
1482 $new_attr{active_price_source} = $price_src;
1483 $new_attr{active_discount_source} = $discount_src;
1484 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1485 $new_attr{project_id} = $record->globalproject_id;
1486 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1488 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1489 # they cannot be retrieved via custom_variables until the order/orderitem is
1490 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1491 $new_attr{custom_variables} = [];
1493 my $texts = get_part_texts($part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription});
1495 $item->assign_attributes(%new_attr, %{ $texts });
1500 sub setup_order_from_cv {
1503 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id));
1505 $order->intnotes($order->customervendor->notes);
1507 if ($order->is_sales) {
1508 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1509 $order->taxincluded(defined($order->customer->taxincluded_checked)
1510 ? $order->customer->taxincluded_checked
1511 : $::myconfig{taxincluded_checked});
1516 # setup custom shipto from form
1518 # The dialog returns form variables starting with 'shipto' and cvars starting
1519 # with 'shiptocvar_'.
1520 # Mark it to be deleted if a shipto from master data is selected
1521 # (i.e. order has a shipto).
1522 # Else, update or create a new custom shipto. If the fields are empty, it
1523 # will not be saved on save.
1524 sub setup_custom_shipto_from_form {
1525 my ($self, $order, $form) = @_;
1527 if ($order->shipto) {
1528 $self->is_custom_shipto_to_delete(1);
1530 my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1532 my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
1533 my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
1535 $custom_shipto->assign_attributes(%$shipto_attrs);
1536 $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
1540 # recalculate prices and taxes
1542 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1546 my %pat = $self->order->calculate_prices_and_taxes();
1548 $self->{taxes} = [];
1549 foreach my $tax_id (keys %{ $pat{taxes_by_tax_id} }) {
1550 my $netamount = sum0 map { $pat{amounts}->{$_}->{amount} } grep { $pat{amounts}->{$_}->{tax_id} == $tax_id } keys %{ $pat{amounts} };
1552 push(@{ $self->{taxes} }, { amount => $pat{taxes_by_tax_id}->{$tax_id},
1553 netamount => $netamount,
1554 tax => SL::DB::Tax->new(id => $tax_id)->load });
1556 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items_sorted}, @{$pat{items}};
1559 # get data for saving, printing, ..., that is not changed in the form
1561 # Only cvars for now.
1562 sub get_unalterable_data {
1565 foreach my $item (@{ $self->order->items }) {
1566 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1567 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1568 foreach my $var (@{ $item->cvars_by_config }) {
1569 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1571 $item->parse_custom_variable_values;
1577 # And remove related files in the spool directory
1582 my $db = $self->order->db;
1584 $db->with_transaction(
1586 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1587 $self->order->delete;
1588 my $spool = $::lx_office_conf{paths}->{spool};
1589 unlink map { "$spool/$_" } @spoolfiles if $spool;
1591 $self->save_history('DELETED');
1594 }) || push(@{$errors}, $db->error);
1601 # And delete items that are deleted in the form.
1606 my $db = $self->order->db;
1608 $db->with_transaction(sub {
1609 # delete custom shipto if it is to be deleted or if it is empty
1610 if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
1611 $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
1612 $self->order->custom_shipto(undef);
1615 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1616 $self->order->save(cascade => 1);
1619 if ($::form->{converted_from_oe_id}) {
1620 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1621 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1622 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1623 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
1624 $src->link_to_record($self->order);
1626 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1628 foreach (@{ $self->order->items_sorted }) {
1629 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1631 SL::DB::RecordLink->new(from_table => 'orderitems',
1632 from_id => $from_id,
1633 to_table => 'orderitems',
1641 $self->save_history('SAVED');
1644 }) || push(@{$errors}, $db->error);
1649 sub workflow_sales_or_request_for_quotation {
1653 my $errors = $self->save();
1655 if (scalar @{ $errors }) {
1656 $self->js->flash('error', $_) for @{ $errors };
1657 return $self->js->render();
1660 my $destination_type = $::form->{type} eq sales_order_type() ? sales_quotation_type() : request_quotation_type();
1662 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1663 $self->{converted_from_oe_id} = delete $::form->{id};
1665 # set item ids to new fake id, to identify them as new items
1666 foreach my $item (@{$self->order->items_sorted}) {
1667 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1671 $::form->{type} = $destination_type;
1672 $self->type($self->init_type);
1673 $self->cv ($self->init_cv);
1677 $self->get_unalterable_data();
1678 $self->pre_render();
1680 # trigger rendering values for second row as hidden, because they
1681 # are loaded only on demand. So we need to keep the values from the
1683 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1687 title => $self->get_title_for('edit'),
1688 %{$self->{template_args}}
1692 sub workflow_sales_or_purchase_order {
1696 my $errors = $self->save();
1698 if (scalar @{ $errors }) {
1699 $self->js->flash('error', $_) foreach @{ $errors };
1700 return $self->js->render();
1703 my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type()
1704 : $::form->{type} eq request_quotation_type() ? purchase_order_type()
1705 : $::form->{type} eq purchase_order_type() ? sales_order_type()
1706 : $::form->{type} eq sales_order_type() ? purchase_order_type()
1709 # check for direct delivery
1710 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
1712 if ( $::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()
1713 && $::form->{use_shipto} && $self->order->shipto) {
1714 $custom_shipto = $self->order->shipto->clone('SL::DB::Order');
1717 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1718 $self->{converted_from_oe_id} = delete $::form->{id};
1720 # set item ids to new fake id, to identify them as new items
1721 foreach my $item (@{$self->order->items_sorted}) {
1722 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1725 if ($::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()) {
1726 if ($::form->{use_shipto}) {
1727 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
1729 # remove any custom shipto if not wanted
1730 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1735 $::form->{type} = $destination_type;
1736 $self->type($self->init_type);
1737 $self->cv ($self->init_cv);
1741 $self->get_unalterable_data();
1742 $self->pre_render();
1744 # trigger rendering values for second row as hidden, because they
1745 # are loaded only on demand. So we need to keep the values from the
1747 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1751 title => $self->get_title_for('edit'),
1752 %{$self->{template_args}}
1760 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1761 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
1762 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1763 $self->{all_languages} = SL::DB::Manager::Language->get_all_sorted();
1764 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1767 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1770 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1772 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1773 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1774 $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
1775 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1776 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1778 my $print_form = Form->new('');
1779 $print_form->{type} = $self->type;
1780 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1781 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1782 form => $print_form,
1783 options => {dialog_name_prefix => 'print_options.',
1787 no_opendocument => 0,
1791 foreach my $item (@{$self->order->orderitems}) {
1792 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1793 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1794 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1797 if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
1798 # calculate shipped qtys here to prevent calling calculate for every item via the items method
1799 SL::Helper::ShippedQty->new->calculate($self->order)->write_to_objects;
1802 if ($self->order->number && $::instance_conf->get_webdav) {
1803 my $webdav = SL::Webdav->new(
1804 type => $self->type,
1805 number => $self->order->number,
1807 my @all_objects = $webdav->get_all_objects;
1808 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1810 link => File::Spec->catfile($_->full_filedescriptor),
1814 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1816 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
1817 edit_periodic_invoices_config calculate_qty kivi.Validator follow_up show_history);
1818 $self->setup_edit_action_bar;
1821 sub setup_edit_action_bar {
1822 my ($self, %params) = @_;
1824 my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
1825 || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
1826 || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
1828 for my $bar ($::request->layout->get('actionbar')) {
1833 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1834 $::instance_conf->get_order_warn_no_deliverydate,
1836 checks => [ 'kivi.Order.check_save_active_periodic_invoices', ['kivi.validate_form','#order_form'] ],
1840 call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1841 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1842 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1844 ], # end of combobox "Save"
1851 t8('Save and Quotation'),
1852 submit => [ '#order_form', { action => "Order/sales_quotation" } ],
1853 only_if => (any { $self->type eq $_ } (sales_order_type())),
1857 submit => [ '#order_form', { action => "Order/request_for_quotation" } ],
1858 only_if => (any { $self->type eq $_ } (purchase_order_type())),
1861 t8('Save and Sales Order'),
1862 submit => [ '#order_form', { action => "Order/sales_order" } ],
1863 only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
1866 t8('Save and Purchase Order'),
1867 call => [ 'kivi.Order.purchase_order_check_for_direct_delivery' ],
1868 only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
1871 t8('Save and Delivery Order'),
1872 call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1873 $::instance_conf->get_order_warn_no_deliverydate,
1875 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1876 only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type()))
1879 t8('Save and Invoice'),
1880 call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1881 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1884 t8('Save and AP Transaction'),
1885 call => [ 'kivi.Order.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
1886 only_if => (any { $self->type eq $_ } (purchase_order_type()))
1889 ], # end of combobox "Workflow"
1896 t8('Save and preview PDF'),
1897 call => [ 'kivi.Order.save', 'preview_pdf', $::instance_conf->get_order_warn_duplicate_parts,
1898 $::instance_conf->get_order_warn_no_deliverydate,
1902 t8('Save and print'),
1903 call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts,
1904 $::instance_conf->get_order_warn_no_deliverydate,
1908 t8('Save and E-mail'),
1909 id => 'save_and_email_action',
1910 call => [ 'kivi.Order.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts,
1911 $::instance_conf->get_order_warn_no_deliverydate,
1913 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1916 t8('Download attachments of all parts'),
1917 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1918 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1919 only_if => $::instance_conf->get_doc_storage,
1921 ], # end of combobox "Export"
1925 call => [ 'kivi.Order.delete_order' ],
1926 confirm => $::locale->text('Do you really want to delete this object?'),
1927 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1928 only_if => $deletion_allowed,
1937 call => [ 'kivi.Order.follow_up_window' ],
1938 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1939 only_if => $::auth->assert('productivity', 1),
1943 call => [ 'set_history_window', $self->order->id, 'id' ],
1944 disabled => !$self->order->id ? t8('This record has not been saved yet.') : undef,
1946 ], # end of combobox "more"
1952 my ($order, $pdf_ref, $params) = @_;
1956 my $print_form = Form->new('');
1957 $print_form->{type} = $order->type;
1958 $print_form->{formname} = $params->{formname} || $order->type;
1959 $print_form->{format} = $params->{format} || 'pdf';
1960 $print_form->{media} = $params->{media} || 'file';
1961 $print_form->{groupitems} = $params->{groupitems};
1962 $print_form->{printer_id} = $params->{printer_id};
1963 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1965 $order->language($params->{language});
1966 $order->flatten_to_form($print_form, format_amounts => 1);
1970 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
1971 $template_ext = 'odt';
1972 $template_type = 'OpenDocument';
1975 # search for the template
1976 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1977 name => $print_form->{formname},
1978 extension => $template_ext,
1979 email => $print_form->{media} eq 'email',
1980 language => $params->{language},
1981 printer_id => $print_form->{printer_id},
1984 if (!defined $template_file) {
1985 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);
1988 return @errors if scalar @errors;
1990 $print_form->throw_on_error(sub {
1992 $print_form->prepare_for_printing;
1994 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1995 format => $print_form->{format},
1996 template_type => $template_type,
1997 template => $template_file,
1998 variables => $print_form,
1999 variable_content_types => {
2000 longdescription => 'html',
2001 partnotes => 'html',
2006 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
2012 sub get_files_for_email_dialog {
2015 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
2017 return %files if !$::instance_conf->get_doc_storage;
2019 if ($self->order->id) {
2020 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
2021 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
2022 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
2023 $files{project_files} = [ SL::File->get_all( object_id => $self->order->globalproject_id, object_type => 'project', file_type => 'attachment') ];
2027 uniq_by { $_->{id} }
2029 +{ id => $_->part->id,
2030 partnumber => $_->part->partnumber }
2031 } @{$self->order->items_sorted};
2033 foreach my $part (@parts) {
2034 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
2035 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
2038 foreach my $key (keys %files) {
2039 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
2045 sub make_periodic_invoices_config_from_yaml {
2046 my ($yaml_config) = @_;
2048 return if !$yaml_config;
2049 my $attr = SL::YAML::Load($yaml_config);
2050 return if 'HASH' ne ref $attr;
2051 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
2055 sub get_periodic_invoices_status {
2056 my ($self, $config) = @_;
2058 return if $self->type ne sales_order_type();
2059 return t8('not configured') if !$config;
2061 my $active = ('HASH' eq ref $config) ? $config->{active}
2062 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
2063 : die "Cannot get status of periodic invoices config";
2065 return $active ? t8('active') : t8('inactive');
2069 my ($self, $action) = @_;
2071 return '' if none { lc($action)} qw(add edit);
2074 # $::locale->text("Add Sales Order");
2075 # $::locale->text("Add Purchase Order");
2076 # $::locale->text("Add Quotation");
2077 # $::locale->text("Add Request for Quotation");
2078 # $::locale->text("Edit Sales Order");
2079 # $::locale->text("Edit Purchase Order");
2080 # $::locale->text("Edit Quotation");
2081 # $::locale->text("Edit Request for Quotation");
2083 $action = ucfirst(lc($action));
2084 return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order")
2085 : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order")
2086 : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation")
2087 : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
2091 sub get_item_cvpartnumber {
2092 my ($self, $item) = @_;
2094 return if !$self->search_cvpartnumber;
2095 return if !$self->order->customervendor;
2097 if ($self->cv eq 'vendor') {
2098 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
2099 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
2100 } elsif ($self->cv eq 'customer') {
2101 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
2102 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
2106 sub get_part_texts {
2107 my ($part_or_id, $language_or_id, %defaults) = @_;
2109 my $part = ref($part_or_id) ? $part_or_id : SL::DB::Part->load_cached($part_or_id);
2110 my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id;
2112 description => $defaults{description} // $part->description,
2113 longdescription => $defaults{longdescription} // $part->notes,
2116 return $texts unless $language_id;
2118 my $translation = SL::DB::Manager::Translation->get_first(
2120 parts_id => $part->id,
2121 language_id => $language_id,
2124 $texts->{description} = $translation->translation if $translation && $translation->translation;
2125 $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription;
2130 sub sales_order_type {
2134 sub purchase_order_type {
2138 sub sales_quotation_type {
2142 sub request_quotation_type {
2143 'request_quotation';
2147 return $_[0]->type eq sales_order_type() ? 'ordnumber'
2148 : $_[0]->type eq purchase_order_type() ? 'ordnumber'
2149 : $_[0]->type eq sales_quotation_type() ? 'quonumber'
2150 : $_[0]->type eq request_quotation_type() ? 'quonumber'
2154 sub save_and_redirect_to {
2155 my ($self, %params) = @_;
2157 my $errors = $self->save();
2159 if (scalar @{ $errors }) {
2160 $self->js->flash('error', $_) foreach @{ $errors };
2161 return $self->js->render();
2164 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
2165 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
2166 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
2167 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
2169 flash_later('info', $text);
2171 $self->redirect_to(%params, id => $self->order->id);
2175 my ($self, $addition) = @_;
2177 my $number_type = $self->order->type =~ m{order} ? 'ordnumber' : 'quonumber';
2178 my $snumbers = $number_type . '_' . $self->order->$number_type;
2180 SL::DB::History->new(
2181 trans_id => $self->order->id,
2182 employee_id => SL::DB::Manager::Employee->current->id,
2183 what_done => $self->order->type,
2184 snumbers => $snumbers,
2185 addition => $addition,
2189 sub store_pdf_to_webdav_and_filemanagement {
2190 my($order, $content, $filename) = @_;
2194 # copy file to webdav folder
2195 if ($order->number && $::instance_conf->get_webdav_documents) {
2196 my $webdav = SL::Webdav->new(
2197 type => $order->type,
2198 number => $order->number,
2200 my $webdav_file = SL::Webdav::File->new(
2202 filename => $filename,
2205 $webdav_file->store(data => \$content);
2208 push @errors, t8('Storing PDF to webdav folder failed: #1', $@);
2211 if ($order->id && $::instance_conf->get_doc_storage) {
2213 SL::File->save(object_id => $order->id,
2214 object_type => $order->type,
2215 mime_type => 'application/pdf',
2216 source => 'created',
2217 file_type => 'document',
2218 file_name => $filename,
2219 file_contents => $content);
2222 push @errors, t8('Storing PDF in storage backend failed: #1', $@);
2237 SL::Controller::Order - controller for orders
2241 This is a new form to enter orders, completely rewritten with the use
2242 of controller and java script techniques.
2244 The aim is to provide the user a better experience and a faster workflow. Also
2245 the code should be more readable, more reliable and better to maintain.
2253 One input row, so that input happens every time at the same place.
2257 Use of pickers where possible.
2261 Possibility to enter more than one item at once.
2265 Item list in a scrollable area, so that the workflow buttons stay at
2270 Reordering item rows with drag and drop is possible. Sorting item rows is
2271 possible (by partnumber, description, qty, sellprice and discount for now).
2275 No C<update> is necessary. All entries and calculations are managed
2276 with ajax-calls and the page only reloads on C<save>.
2280 User can see changes immediately, because of the use of java script
2291 =item * C<SL/Controller/Order.pm>
2295 =item * C<template/webpages/order/form.html>
2299 =item * C<template/webpages/order/tabs/basic_data.html>
2301 Main tab for basic_data.
2303 This is the only tab here for now. "linked records" and "webdav" tabs are
2304 reused from generic code.
2308 =item * C<template/webpages/order/tabs/_business_info_row.html>
2310 For displaying information on business type
2312 =item * C<template/webpages/order/tabs/_item_input.html>
2314 The input line for items
2316 =item * C<template/webpages/order/tabs/_row.html>
2318 One row for already entered items
2320 =item * C<template/webpages/order/tabs/_tax_row.html>
2322 Displaying tax information
2324 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
2326 Dialog for selecting price and discount sources
2330 =item * C<js/kivi.Order.js>
2332 java script functions
2342 =item * price sources: little symbols showing better price / better discount
2344 =item * select units in input row?
2346 =item * check for direct delivery (workflow sales order -> purchase order)
2348 =item * access rights
2350 =item * display weights
2354 =item * optional client/user behaviour
2356 (transactions has to be set - department has to be set -
2357 force project if enabled in client config - transport cost reminder)
2361 =head1 KNOWN BUGS AND CAVEATS
2367 Customer discount is not displayed as a valid discount in price source popup
2368 (this might be a bug in price sources)
2370 (I cannot reproduce this (Bernd))
2374 No indication that <shift>-up/down expands/collapses second row.
2378 Inline creation of parts is not currently supported
2382 Table header is not sticky in the scrolling area.
2386 Sorting does not include C<position>, neither does reordering.
2388 This behavior was implemented intentionally. But we can discuss, which behavior
2389 should be implemented.
2393 =head1 To discuss / Nice to have
2399 How to expand/collapse second row. Now it can be done clicking the icon or
2404 Possibility to select PriceSources in input row?
2408 This controller uses a (changed) copy of the template for the PriceSource
2409 dialog. Maybe there could be used one code source.
2413 Rounding-differences between this controller (PriceTaxCalculator) and the old
2414 form. This is not only a problem here, but also in all parts using the PTC.
2415 There exists a ticket and a patch. This patch should be testet.
2419 An indicator, if the actual inputs are saved (like in an
2420 editor or on text processing application).
2424 A warning when leaving the page without saveing unchanged inputs.
2431 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>