1 package SL::Controller::Order;
4 use parent qw(SL::Controller::Base);
6 use SL::Helper::Flash qw(flash_later);
7 use SL::Presenter::Tag qw(select_tag hidden_tag);
8 use SL::Locale::String qw(t8);
9 use SL::SessionFile::Random;
14 use SL::Util qw(trim);
20 use SL::DB::PartsGroup;
23 use SL::DB::RecordLink;
25 use SL::Helper::CreatePDF qw(:all);
26 use SL::Helper::PrintOptions;
27 use SL::Helper::ShippedQty;
28 use SL::Helper::UserPreferences::PositionsScrollbar;
29 use SL::Helper::UserPreferences::UpdatePositions;
31 use SL::Controller::Helper::GetModels;
33 use List::Util qw(first);
34 use List::UtilsBy qw(sort_by uniq_by);
35 use List::MoreUtils qw(any none pairwise first_index);
36 use English qw(-no_match_vars);
41 use Rose::Object::MakeMethods::Generic
43 scalar => [ qw(item_ids_to_delete) ],
44 'scalar --get_set_init' => [ qw(order valid_types type cv p multi_items_models all_price_factors search_cvpartnumber show_update_button) ],
49 __PACKAGE__->run_before('check_auth');
51 __PACKAGE__->run_before('recalc',
52 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
55 __PACKAGE__->run_before('get_unalterable_data',
56 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
67 $self->order->transdate(DateTime->now_local());
68 my $extra_days = $self->{type} eq 'sales_quotation' ? $::instance_conf->get_reqdate_interval :
69 $self->{type} eq 'sales_order' ? $::instance_conf->get_delivery_date_interval : 1;
70 $self->order->reqdate(DateTime->today_local->next_workday(extra_days => $extra_days)) if !$self->order->reqdate;
76 title => $self->get_title_for('add'),
77 %{$self->{template_args}}
81 # edit an existing order
89 # this is to edit an order from an unsaved order object
91 # set item ids to new fake id, to identify them as new items
92 foreach my $item (@{$self->order->items_sorted}) {
93 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
95 # trigger rendering values for second row/longdescription as hidden,
96 # because they are loaded only on demand. So we need to keep the values
98 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
99 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
106 title => $self->get_title_for('edit'),
107 %{$self->{template_args}}
111 # edit a collective order (consisting of one or more existing orders)
112 sub action_edit_collective {
116 my @multi_ids = map {
117 $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1}
118 } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form;
120 # fall back to add if no ids are given
121 if (scalar @multi_ids == 0) {
126 # fall back to save as new if only one id is given
127 if (scalar @multi_ids == 1) {
128 $self->order(SL::DB::Order->new(id => $multi_ids[0])->load);
129 $self->action_save_as_new();
133 # make new order from given orders
134 my @multi_orders = map { SL::DB::Order->new(id => $_)->load } @multi_ids;
135 $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders;
136 $self->order(SL::DB::Order->new_from_multi(\@multi_orders, sort_sources_by => 'transdate'));
138 $self->action_edit();
145 my $errors = $self->delete();
147 if (scalar @{ $errors }) {
148 $self->js->flash('error', $_) foreach @{ $errors };
149 return $self->js->render();
152 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been deleted')
153 : $self->type eq purchase_order_type() ? $::locale->text('The order has been deleted')
154 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been deleted')
155 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been deleted')
157 flash_later('info', $text);
159 my @redirect_params = (
164 $self->redirect_to(@redirect_params);
171 my $errors = $self->save();
173 if (scalar @{ $errors }) {
174 $self->js->flash('error', $_) foreach @{ $errors };
175 return $self->js->render();
178 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
179 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
180 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
181 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
183 flash_later('info', $text);
185 my @redirect_params = (
188 id => $self->order->id,
191 $self->redirect_to(@redirect_params);
194 # save the order as new document an open it for edit
195 sub action_save_as_new {
198 my $order = $self->order;
201 $self->js->flash('error', t8('This object has not been saved yet.'));
202 return $self->js->render();
205 # load order from db to check if values changed
206 my $saved_order = SL::DB::Order->new(id => $order->id)->load;
209 # Lets assign a new number if the user hasn't changed the previous one.
210 # If it has been changed manually then use it as-is.
211 $new_attrs{number} = (trim($order->number) eq $saved_order->number)
213 : trim($order->number);
215 # Clear transdate unless changed
216 $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
217 ? DateTime->today_local
220 # Set new reqdate unless changed
221 if ($order->reqdate == $saved_order->reqdate) {
222 my $extra_days = $self->{type} eq 'sales_quotation' ? $::instance_conf->get_reqdate_interval :
223 $self->{type} eq 'sales_order' ? $::instance_conf->get_delivery_date_interval : 1;
224 $new_attrs{reqdate} = DateTime->today_local->next_workday(extra_days => $extra_days);
226 $new_attrs{reqdate} = $order->reqdate;
230 $new_attrs{employee} = SL::DB::Manager::Employee->current;
232 # Create new record from current one
233 $self->order(SL::DB::Order->new_from($order, destination_type => $order->type, attributes => \%new_attrs));
235 # no linked records on save as new
236 delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
239 $self->action_save();
244 # This is called if "print" is pressed in the print dialog.
245 # If PDF creation was requested and succeeded, the pdf is stored in a session
246 # file and the filename is stored as session value with an unique key. A
247 # javascript function with this key is then called. This function calls the
248 # download action below (action_download_pdf), which offers the file for
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};
268 # only pdf and opendocument by now
269 if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf)) {
270 return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
273 # only screen or printer by now
274 if (none { $media eq $_ } qw(screen printer)) {
275 return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
279 $language = SL::DB::Language->new(id => $::form->{print_options}->{language_id})->load if $::form->{print_options}->{language_id};
281 # create a form for generate_attachment_filename
282 my $form = Form->new;
283 $form->{$self->nr_key()} = $self->order->number;
284 $form->{type} = $self->type;
285 $form->{format} = $format;
286 $form->{formname} = $formname;
287 $form->{language} = '_' . $language->template_code if $language;
288 my $pdf_filename = $form->generate_attachment_filename();
291 my @errors = generate_pdf($self->order, \$pdf, { format => $format,
292 formname => $formname,
293 language => $language,
294 groupitems => $groupitems });
295 if (scalar @errors) {
296 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
299 if ($media eq 'screen') {
301 my $sfile = SL::SessionFile::Random->new(mode => "w");
302 $sfile->fh->print($pdf);
305 my $key = join('_', Time::HiRes::gettimeofday(), int rand 1000000000000);
306 $::auth->set_session_value("Order::print-${key}" => $sfile->file_name);
309 ->run('kivi.Order.download_pdf', $pdf_filename, $key)
310 ->flash('info', t8('The PDF has been created'));
312 } elsif ($media eq 'printer') {
314 my $printer_id = $::form->{print_options}->{printer_id};
315 SL::DB::Printer->new(id => $printer_id)->load->print_document(
320 $self->js->flash('info', t8('The PDF has been printed'));
323 # copy file to webdav folder
324 if ($self->order->number && $::instance_conf->get_webdav_documents) {
325 my $webdav = SL::Webdav->new(
327 number => $self->order->number,
329 my $webdav_file = SL::Webdav::File->new(
331 filename => $pdf_filename,
334 $webdav_file->store(data => \$pdf);
337 $self->js->flash('error', t8('Storing PDF to webdav folder failed: #1', $@));
340 if ($self->order->number && $::instance_conf->get_doc_storage) {
342 SL::File->save(object_id => $self->order->id,
343 object_type => $self->type,
344 mime_type => 'application/pdf',
346 file_type => 'document',
347 file_name => $pdf_filename,
348 file_contents => $pdf);
351 $self->js->flash('error', t8('Storing PDF in storage backend failed: #1', $@));
357 # offer pdf for download
359 # It needs to get the key for the session value to get the pdf file.
360 sub action_download_pdf {
363 my $key = $::form->{key};
364 my $tmp_filename = $::auth->get_session_value("Order::print-${key}");
365 return $self->send_file(
367 type => SL::MIME->mime_type_from_ext($::form->{pdf_filename}),
368 name => $::form->{pdf_filename},
372 # open the email dialog
373 sub action_show_email_dialog {
376 my $cv_method = $self->cv;
378 if (!$self->order->$cv_method) {
379 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'))
384 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
385 $email_form->{to} ||= $self->order->$cv_method->email;
386 $email_form->{cc} = $self->order->$cv_method->cc;
387 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
388 # Todo: get addresses from shipto, if any
390 my $form = Form->new;
391 $form->{$self->nr_key()} = $self->order->number;
392 $form->{formname} = $self->type;
393 $form->{type} = $self->type;
394 $form->{language} = 'de';
395 $form->{format} = 'pdf';
397 $email_form->{subject} = $form->generate_email_subject();
398 $email_form->{attachment_filename} = $form->generate_attachment_filename();
399 $email_form->{message} = $form->generate_email_body();
400 $email_form->{js_send_function} = 'kivi.Order.send_email()';
402 my %files = $self->get_files_for_email_dialog();
403 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
404 email_form => $email_form,
405 show_bcc => $::auth->assert('email_bcc', 'may fail'),
407 is_customer => $self->cv eq 'customer',
411 ->run('kivi.Order.show_email_dialog', $dialog_html)
418 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
419 sub action_send_email {
422 my $errors = $self->save();
424 if (scalar @{ $errors }) {
425 $self->js->run('kivi.Order.close_email_dialog');
426 $self->js->flash('error', $_) foreach @{ $errors };
427 return $self->js->render();
430 $self->js_reset_order_and_item_ids_after_save;
432 my $email_form = delete $::form->{email_form};
433 my %field_names = (to => 'email');
435 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
437 # for Form::cleanup which may be called in Form::send_email
438 $::form->{cwd} = getcwd();
439 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
441 $::form->{$_} = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
442 $::form->{media} = 'email';
444 if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) {
446 $language = SL::DB::Language->new(id => $::form->{print_options}->{language_id})->load if $::form->{print_options}->{language_id};
449 my @errors = generate_pdf($self->order, \$pdf, {media => $::form->{media},
450 format => $::form->{print_options}->{format},
451 formname => $::form->{print_options}->{formname},
452 language => $language,
453 groupitems => $::form->{print_options}->{groupitems}});
454 if (scalar @errors) {
455 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
458 my $sfile = SL::SessionFile::Random->new(mode => "w");
459 $sfile->fh->print($pdf);
462 $::form->{tmpfile} = $sfile->file_name;
463 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
466 $::form->send_email(\%::myconfig, 'pdf');
469 my $intnotes = $self->order->intnotes;
470 $intnotes .= "\n\n" if $self->order->intnotes;
471 $intnotes .= t8('[email]') . "\n";
472 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
473 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
474 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
475 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
476 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
477 $intnotes .= t8('Message') . ": " . $::form->{message};
479 $self->order->update_attributes(intnotes => $intnotes);
482 ->val('#order_intnotes', $intnotes)
483 ->run('kivi.Order.close_email_dialog')
484 ->flash('info', t8('The email has been sent.'))
488 # open the periodic invoices config dialog
490 # If there are values in the form (i.e. dialog was opened before),
491 # then use this values. Create new ones, else.
492 sub action_show_periodic_invoices_config_dialog {
495 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
496 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
497 $config ||= SL::DB::PeriodicInvoicesConfig->new(periodicity => 'm',
498 order_value_periodicity => 'p', # = same as periodicity
499 start_date_as_date => $::form->{transdate_as_date} || $::form->current_date,
500 extend_automatically_by => 12,
502 email_subject => GenericTranslations->get(
503 language_id => $::form->{language_id},
504 translation_type =>"preset_text_periodic_invoices_email_subject"),
505 email_body => GenericTranslations->get(
506 language_id => $::form->{language_id},
507 translation_type =>"preset_text_periodic_invoices_email_body"),
509 $config->periodicity('m') if none { $_ eq $config->periodicity } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
510 $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
512 $::form->get_lists(printers => "ALL_PRINTERS",
513 charts => { key => 'ALL_CHARTS',
514 transdate => 'current_date' });
516 $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
518 if ($::form->{customer_id}) {
519 $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
520 $::form->{email_recipient_invoice_address} = SL::DB::Manager::Customer->find_by(id => $::form->{customer_id})->invoice_mail;
523 $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
525 popup_js_close_function => 'kivi.Order.close_periodic_invoices_config_dialog()',
526 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
531 # assign the values of the periodic invoices config dialog
532 # as yaml in the hidden tag and set the status.
533 sub action_assign_periodic_invoices_config {
536 $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
538 my $config = { active => $::form->{active} ? 1 : 0,
539 terminated => $::form->{terminated} ? 1 : 0,
540 direct_debit => $::form->{direct_debit} ? 1 : 0,
541 periodicity => (any { $_ eq $::form->{periodicity} } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES) ? $::form->{periodicity} : 'm',
542 order_value_periodicity => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
543 start_date_as_date => $::form->{start_date_as_date},
544 end_date_as_date => $::form->{end_date_as_date},
545 first_billing_date_as_date => $::form->{first_billing_date_as_date},
546 print => $::form->{print} ? 1 : 0,
547 printer_id => $::form->{print} ? $::form->{printer_id} * 1 : undef,
548 copies => $::form->{copies} * 1 ? $::form->{copies} : 1,
549 extend_automatically_by => $::form->{extend_automatically_by} * 1 || undef,
550 ar_chart_id => $::form->{ar_chart_id} * 1,
551 send_email => $::form->{send_email} ? 1 : 0,
552 email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
553 email_recipient_address => $::form->{email_recipient_address},
554 email_sender => $::form->{email_sender},
555 email_subject => $::form->{email_subject},
556 email_body => $::form->{email_body},
559 my $periodic_invoices_config = SL::YAML::Dump($config);
561 my $status = $self->get_periodic_invoices_status($config);
564 ->remove('#order_periodic_invoices_config')
565 ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
566 ->run('kivi.Order.close_periodic_invoices_config_dialog')
567 ->html('#periodic_invoices_status', $status)
568 ->flash('info', t8('The periodic invoices config has been assigned.'))
572 sub action_get_has_active_periodic_invoices {
575 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
576 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
578 my $has_active_periodic_invoices =
579 $self->type eq sales_order_type()
582 && (!$config->end_date || ($config->end_date > DateTime->today_local))
583 && $config->get_previous_billed_period_start_date;
585 $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
588 # save the order and redirect to the frontend subroutine for a new
590 sub action_save_and_delivery_order {
593 my $errors = $self->save();
595 if (scalar @{ $errors }) {
596 $self->js->flash('error', $_) foreach @{ $errors };
597 return $self->js->render();
600 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
601 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
602 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
603 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
605 flash_later('info', $text);
607 my @redirect_params = (
608 controller => 'oe.pl',
609 action => 'oe_delivery_order_from_order',
610 id => $self->order->id,
613 $self->redirect_to(@redirect_params);
616 # save the order and redirect to the frontend subroutine for a new
618 sub action_save_and_invoice {
621 my $errors = $self->save();
623 if (scalar @{ $errors }) {
624 $self->js->flash('error', $_) foreach @{ $errors };
625 return $self->js->render();
628 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
629 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
630 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
631 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
633 flash_later('info', $text);
635 my @redirect_params = (
636 controller => 'oe.pl',
637 action => 'oe_invoice_from_order',
638 id => $self->order->id,
641 $self->redirect_to(@redirect_params);
644 # workflow from sales quotation to sales order
645 sub action_sales_order {
646 $_[0]->workflow_sales_or_purchase_order();
649 # workflow from rfq to purchase order
650 sub action_purchase_order {
651 $_[0]->workflow_sales_or_purchase_order();
654 # workflow from purchase order to ap transaction
655 sub action_save_and_ap_transaction {
658 my $errors = $self->save();
660 if (scalar @{ $errors }) {
661 $self->js->flash('error', $_) foreach @{ $errors };
662 return $self->js->render();
665 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
666 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
667 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
668 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
670 flash_later('info', $text);
672 my @redirect_params = (
673 controller => 'ap.pl',
674 action => 'add_from_purchase_order',
675 id => $self->order->id,
678 $self->redirect_to(@redirect_params);
681 # set form elements in respect to a changed customer or vendor
683 # This action is called on an change of the customer/vendor picker.
684 sub action_customer_vendor_changed {
687 setup_order_from_cv($self->order);
690 my $cv_method = $self->cv;
692 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
693 $self->js->show('#cp_row');
695 $self->js->hide('#cp_row');
698 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
699 $self->js->show('#shipto_row');
701 $self->js->hide('#shipto_row');
704 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
707 ->replaceWith('#order_cp_id', $self->build_contact_select)
708 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
709 ->replaceWith('#business_info_row', $self->build_business_info_row)
710 ->val( '#order_taxzone_id', $self->order->taxzone_id)
711 ->val( '#order_taxincluded', $self->order->taxincluded)
712 ->val( '#order_payment_id', $self->order->payment_id)
713 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
714 ->val( '#order_intnotes', $self->order->intnotes)
715 ->val( '#language_id', $self->order->$cv_method->language_id)
716 ->focus( '#order_' . $self->cv . '_id');
718 $self->js_redisplay_amounts_and_taxes;
719 $self->js_redisplay_cvpartnumbers;
723 # open the dialog for customer/vendor details
724 sub action_show_customer_vendor_details_dialog {
727 my $is_customer = 'customer' eq $::form->{vc};
730 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
732 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
735 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
736 $details{discount_as_percent} = $cv->discount_as_percent;
737 $details{creditlimt} = $cv->creditlimit_as_number;
738 $details{business} = $cv->business->description if $cv->business;
739 $details{language} = $cv->language_obj->description if $cv->language_obj;
740 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
741 $details{payment_terms} = $cv->payment->description if $cv->payment;
742 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
744 foreach my $entry (@{ $cv->shipto }) {
745 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
747 foreach my $entry (@{ $cv->contacts }) {
748 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
751 $_[0]->render('common/show_vc_details', { layout => 0 },
752 is_customer => $is_customer,
757 # called if a unit in an existing item row is changed
758 sub action_unit_changed {
761 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
762 my $item = $self->order->items_sorted->[$idx];
764 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
765 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
770 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
771 $self->js_redisplay_line_values;
772 $self->js_redisplay_amounts_and_taxes;
776 # add an item row for a new item entered in the input row
777 sub action_add_item {
780 my $form_attr = $::form->{add_item};
782 return unless $form_attr->{parts_id};
784 my $item = new_item($self->order, $form_attr);
786 $self->order->add_items($item);
790 $self->get_item_cvpartnumber($item);
792 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
793 my $row_as_html = $self->p->render('order/tabs/_row',
797 ALL_PRICE_FACTORS => $self->all_price_factors,
798 SEARCH_CVPARTNUMBER => $self->search_cvpartnumber,
799 SHOW_UPDATE_BUTTON => $self->show_update_button,
803 ->append('#row_table_id', $row_as_html);
805 if ( $item->part->is_assortment ) {
806 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
807 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
808 my $attr = { parts_id => $assortment_item->parts_id,
809 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
810 unit => $assortment_item->unit,
811 description => $assortment_item->part->description,
813 my $item = new_item($self->order, $attr);
815 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
816 $item->discount(1) unless $assortment_item->charge;
818 $self->order->add_items( $item );
820 $self->get_item_cvpartnumber($item);
821 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
822 my $row_as_html = $self->p->render('order/tabs/_row',
826 ALL_PRICE_FACTORS => $self->all_price_factors,
827 SEARCH_CVPARTNUMBER => $self->search_cvpartnumber,
828 SHOW_UPDATE_BUTTON => $self->show_update_button,
831 ->append('#row_table_id', $row_as_html);
836 ->val('.add_item_input', '')
837 ->run('kivi.Order.init_row_handlers')
838 ->run('kivi.Order.row_table_scroll_down')
839 ->run('kivi.Order.renumber_positions')
840 ->focus('#add_item_parts_id_name');
842 $self->js_redisplay_amounts_and_taxes;
846 # open the dialog for entering multiple items at once
847 sub action_show_multi_items_dialog {
848 $_[0]->render('order/tabs/_multi_items_dialog', { layout => 0 },
849 all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
852 # update the filter results in the multi item dialog
853 sub action_multi_items_update_result {
856 $::form->{multi_items}->{filter}->{obsolete} = 0;
858 my $count = $_[0]->multi_items_models->count;
861 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
862 $_[0]->render($text, { layout => 0 });
863 } elsif ($count > $max_count) {
864 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
865 $_[0]->render($text, { layout => 0 });
867 my $multi_items = $_[0]->multi_items_models->get;
868 $_[0]->render('order/tabs/_multi_items_result', { layout => 0 },
869 multi_items => $multi_items);
873 # add item rows for multiple items at once
874 sub action_add_multi_items {
877 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_multi_items} };
878 return $self->js->render() unless scalar @form_attr;
881 foreach my $attr (@form_attr) {
882 my $item = new_item($self->order, $attr);
884 if ( $item->part->is_assortment ) {
885 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
886 my $attr = { parts_id => $assortment_item->parts_id,
887 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
888 unit => $assortment_item->unit,
889 description => $assortment_item->part->description,
891 my $item = new_item($self->order, $attr);
893 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
894 $item->discount(1) unless $assortment_item->charge;
899 $self->order->add_items(@items);
903 foreach my $item (@items) {
904 $self->get_item_cvpartnumber($item);
905 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
906 my $row_as_html = $self->p->render('order/tabs/_row',
910 ALL_PRICE_FACTORS => $self->all_price_factors,
911 SEARCH_CVPARTNUMBER => $self->search_cvpartnumber,
912 SHOW_UPDATE_BUTTON => $self->show_update_button,
915 $self->js->append('#row_table_id', $row_as_html);
919 ->run('kivi.Order.close_multi_items_dialog')
920 ->run('kivi.Order.init_row_handlers')
921 ->run('kivi.Order.row_table_scroll_down')
922 ->run('kivi.Order.renumber_positions')
923 ->focus('#add_item_parts_id_name');
925 $self->js_redisplay_amounts_and_taxes;
929 # recalculate all linetotals, amounts and taxes and redisplay them
930 sub action_recalc_amounts_and_taxes {
935 $self->js_redisplay_line_values;
936 $self->js_redisplay_amounts_and_taxes;
940 # redisplay item rows if they are sorted by an attribute
941 sub action_reorder_items {
945 partnumber => sub { $_[0]->part->partnumber },
946 description => sub { $_[0]->description },
947 qty => sub { $_[0]->qty },
948 sellprice => sub { $_[0]->sellprice },
949 discount => sub { $_[0]->discount },
950 cvpartnumber => sub { $_[0]->{cvpartnumber} },
953 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
955 my $method = $sort_keys{$::form->{order_by}};
956 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
957 if ($::form->{sort_dir}) {
958 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
959 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
961 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
964 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
965 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
967 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
971 ->run('kivi.Order.redisplay_items', \@to_sort)
975 # show the popup to choose a price/discount source
976 sub action_price_popup {
979 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
980 my $item = $self->order->items_sorted->[$idx];
982 $self->render_price_dialog($item);
985 # get the longdescription for an item if the dialog to enter/change the
986 # longdescription was opened and the longdescription is empty
988 # If this item is new, get the longdescription from Part.
989 # Otherwise get it from OrderItem.
990 sub action_get_item_longdescription {
993 if ($::form->{item_id}) {
994 $longdescription = SL::DB::OrderItem->new(id => $::form->{item_id})->load->longdescription;
995 } elsif ($::form->{parts_id}) {
996 $longdescription = SL::DB::Part->new(id => $::form->{parts_id})->load->notes;
998 $_[0]->render(\ $longdescription, { type => 'text' });
1001 # load the second row for one or more items
1003 # This action gets the html code for all items second rows by rendering a template for
1004 # the second row and sets the html code via client js.
1005 sub action_load_second_rows {
1008 $self->recalc() if $self->order->is_sales; # for margin calculation
1010 foreach my $item_id (@{ $::form->{item_ids} }) {
1011 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1012 my $item = $self->order->items_sorted->[$idx];
1014 $self->js_load_second_row($item, $item_id, 0);
1017 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
1019 $self->js->render();
1022 # update description, notes and sellprice from master data
1023 sub action_update_row_from_master_data {
1026 foreach my $item_id (@{ $::form->{item_ids} }) {
1027 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1028 my $item = $self->order->items_sorted->[$idx];
1030 $item->description($item->part->description);
1031 $item->longdescription($item->part->notes);
1033 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1036 if ($item->part->is_assortment) {
1037 # add assortment items with price 0, as the components carry the price
1038 $price_src = $price_source->price_from_source("");
1039 $price_src->price(0);
1041 $price_src = $price_source->best_price
1042 ? $price_source->best_price
1043 : $price_source->price_from_source("");
1044 $price_src->price(0) if !$price_source->best_price;
1047 $item->sellprice($price_src->price);
1048 $item->active_price_source($price_src);
1051 ->run('kivi.Order.update_sellprice', $item_id, $item->sellprice_as_number)
1052 ->val('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
1053 ->val('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
1057 $self->js_redisplay_line_values;
1058 $self->js_redisplay_amounts_and_taxes;
1060 $self->js->render();
1063 sub js_load_second_row {
1064 my ($self, $item, $item_id, $do_parse) = @_;
1067 # Parse values from form (they are formated while rendering (template)).
1068 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1069 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1070 foreach my $var (@{ $item->cvars_by_config }) {
1071 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1073 $item->parse_custom_variable_values;
1076 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1079 ->html('#second_row_' . $item_id, $row_as_html)
1080 ->data('#second_row_' . $item_id, 'loaded', 1);
1083 sub js_redisplay_line_values {
1086 my $is_sales = $self->order->is_sales;
1088 # sales orders with margins
1093 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1094 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1095 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1096 ]} @{ $self->order->items_sorted };
1100 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1101 ]} @{ $self->order->items_sorted };
1105 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1108 sub js_redisplay_amounts_and_taxes {
1111 if (scalar @{ $self->{taxes} }) {
1112 $self->js->show('#taxincluded_row_id');
1114 $self->js->hide('#taxincluded_row_id');
1117 if ($self->order->taxincluded) {
1118 $self->js->hide('#subtotal_row_id');
1120 $self->js->show('#subtotal_row_id');
1123 if ($self->order->is_sales) {
1124 my $is_neg = $self->order->marge_total < 0;
1126 ->html('#marge_total_id', $::form->format_amount(\%::myconfig, $self->order->marge_total, 2))
1127 ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
1128 ->action_if( $is_neg, 'addClass', '#marge_total_id', 'plus0')
1129 ->action_if( $is_neg, 'addClass', '#marge_percent_id', 'plus0')
1130 ->action_if( $is_neg, 'addClass', '#marge_percent_sign_id', 'plus0')
1131 ->action_if(!$is_neg, 'removeClass', '#marge_total_id', 'plus0')
1132 ->action_if(!$is_neg, 'removeClass', '#marge_percent_id', 'plus0')
1133 ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
1137 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
1138 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
1139 ->remove('.tax_row')
1140 ->insertBefore($self->build_tax_rows, '#amount_row_id');
1143 sub js_redisplay_cvpartnumbers {
1146 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1148 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1151 ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
1154 sub js_reset_order_and_item_ids_after_save {
1158 ->val('#id', $self->order->id)
1159 ->val('#converted_from_oe_id', '')
1160 ->val('#order_' . $self->nr_key(), $self->order->number);
1163 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1164 next if !$self->order->items_sorted->[$idx]->id;
1165 next if $form_item_id !~ m{^new};
1167 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1168 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1169 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1173 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1180 sub init_valid_types {
1181 [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
1187 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1188 die "Not a valid type for order";
1191 $self->type($::form->{type});
1197 my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer'
1198 : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
1199 : die "Not a valid type for order";
1204 sub init_search_cvpartnumber {
1207 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1208 my $search_cvpartnumber;
1209 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1210 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1212 return $search_cvpartnumber;
1215 sub init_show_update_button {
1218 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1229 # model used to filter/display the parts in the multi-items dialog
1230 sub init_multi_items_models {
1231 SL::Controller::Helper::GetModels->new(
1232 controller => $_[0],
1234 with_objects => [ qw(unit_obj) ],
1235 disable_plugin => 'paginated',
1236 source => $::form->{multi_items},
1242 partnumber => t8('Partnumber'),
1243 description => t8('Description')}
1247 sub init_all_price_factors {
1248 SL::DB::Manager::PriceFactor->get_all;
1254 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
1256 my $right = $right_for->{ $self->type };
1257 $right ||= 'DOES_NOT_EXIST';
1259 $::auth->assert($right);
1262 # build the selection box for contacts
1264 # Needed, if customer/vendor changed.
1265 sub build_contact_select {
1268 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1269 value_key => 'cp_id',
1270 title_key => 'full_name_dep',
1271 default => $self->order->cp_id,
1273 style => 'width: 300px',
1277 # build the selection box for shiptos
1279 # Needed, if customer/vendor changed.
1280 sub build_shipto_select {
1283 select_tag('order.shipto_id', [ $self->order->{$self->cv}->shipto ],
1284 value_key => 'shipto_id',
1285 title_key => 'displayable_id',
1286 default => $self->order->shipto_id,
1288 style => 'width: 300px',
1292 # render the info line for business
1294 # Needed, if customer/vendor changed.
1295 sub build_business_info_row
1297 $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
1300 # build the rows for displaying taxes
1302 # Called if amounts where recalculated and redisplayed.
1303 sub build_tax_rows {
1307 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
1308 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
1310 return $rows_as_html;
1314 sub render_price_dialog {
1315 my ($self, $record_item) = @_;
1317 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1321 'kivi.io.price_chooser_dialog',
1322 t8('Available Prices'),
1323 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1328 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1329 # $self->js->show('#dialog_flash_error');
1338 return if !$::form->{id};
1340 $self->order(SL::DB::Order->new(id => $::form->{id})->load);
1343 # load or create a new order object
1345 # And assign changes from the form to this object.
1346 # If the order is loaded from db, check if items are deleted in the form,
1347 # remove them form the object and collect them for removing from db on saving.
1348 # Then create/update items from form (via make_item) and add them.
1352 # add_items adds items to an order with no items for saving, but they cannot
1353 # be retrieved via items until the order is saved. Adding empty items to new
1354 # order here solves this problem.
1356 $order = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1357 $order ||= SL::DB::Order->new(orderitems => [],
1358 quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())));
1360 my $cv_id_method = $self->cv . '_id';
1361 if (!$::form->{id} && $::form->{$cv_id_method}) {
1362 $order->$cv_id_method($::form->{$cv_id_method});
1363 setup_order_from_cv($order);
1366 my $form_orderitems = delete $::form->{order}->{orderitems};
1367 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1369 $order->assign_attributes(%{$::form->{order}});
1371 if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) {
1372 my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
1373 $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
1376 # remove deleted items
1377 $self->item_ids_to_delete([]);
1378 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1379 my $item = $order->orderitems->[$idx];
1380 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1381 splice @{$order->orderitems}, $idx, 1;
1382 push @{$self->item_ids_to_delete}, $item->id;
1388 foreach my $form_attr (@{$form_orderitems}) {
1389 my $item = make_item($order, $form_attr);
1390 $item->position($pos);
1394 $order->add_items(grep {!$_->id} @items);
1399 # create or update items from form
1401 # Make item objects from form values. For items already existing read from db.
1402 # Create a new item else. And assign attributes.
1404 my ($record, $attr) = @_;
1407 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1409 my $is_new = !$item;
1411 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1412 # they cannot be retrieved via custom_variables until the order/orderitem is
1413 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1414 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1416 $item->assign_attributes(%$attr);
1417 $item->longdescription($item->part->notes) if $is_new && !defined $attr->{longdescription};
1418 $item->project_id($record->globalproject_id) if $is_new && !defined $attr->{project_id};
1419 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if $is_new && !defined $attr->{lastcost_as_number};
1426 # This is used to add one item
1428 my ($record, $attr) = @_;
1430 my $item = SL::DB::OrderItem->new;
1432 # Remove attributes where the user left or set the inputs empty.
1433 # So these attributes will be undefined and we can distinguish them
1434 # from zero later on.
1435 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1436 delete $attr->{$_} if $attr->{$_} eq '';
1439 $item->assign_attributes(%$attr);
1441 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1442 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1444 $item->unit($part->unit) if !$item->unit;
1447 if ( $part->is_assortment ) {
1448 # add assortment items with price 0, as the components carry the price
1449 $price_src = $price_source->price_from_source("");
1450 $price_src->price(0);
1451 } elsif (defined $item->sellprice) {
1452 $price_src = $price_source->price_from_source("");
1453 $price_src->price($item->sellprice);
1455 $price_src = $price_source->best_price
1456 ? $price_source->best_price
1457 : $price_source->price_from_source("");
1458 $price_src->price(0) if !$price_source->best_price;
1462 if (defined $item->discount) {
1463 $discount_src = $price_source->discount_from_source("");
1464 $discount_src->discount($item->discount);
1466 $discount_src = $price_source->best_discount
1467 ? $price_source->best_discount
1468 : $price_source->discount_from_source("");
1469 $discount_src->discount(0) if !$price_source->best_discount;
1473 $new_attr{part} = $part;
1474 $new_attr{description} = $part->description if ! $item->description;
1475 $new_attr{qty} = 1.0 if ! $item->qty;
1476 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1477 $new_attr{sellprice} = $price_src->price;
1478 $new_attr{discount} = $discount_src->discount;
1479 $new_attr{active_price_source} = $price_src;
1480 $new_attr{active_discount_source} = $discount_src;
1481 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1482 $new_attr{project_id} = $record->globalproject_id;
1483 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1485 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1486 # they cannot be retrieved via custom_variables until the order/orderitem is
1487 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1488 $new_attr{custom_variables} = [];
1490 $item->assign_attributes(%new_attr);
1495 sub setup_order_from_cv {
1498 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id));
1500 $order->intnotes($order->customervendor->notes);
1502 if ($order->is_sales) {
1503 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1504 $order->taxincluded(defined($order->customer->taxincluded_checked)
1505 ? $order->customer->taxincluded_checked
1506 : $::myconfig{taxincluded_checked});
1511 # recalculate prices and taxes
1513 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1517 # bb: todo: currency later
1518 $self->order->currency_id($::instance_conf->get_currency_id());
1520 my %pat = $self->order->calculate_prices_and_taxes();
1521 $self->{taxes} = [];
1522 foreach my $tax_chart_id (keys %{ $pat{taxes} }) {
1523 my $tax = SL::DB::Manager::Tax->find_by(chart_id => $tax_chart_id);
1525 my @amount_keys = grep { $pat{amounts}->{$_}->{tax_id} == $tax->id } keys %{ $pat{amounts} };
1526 push(@{ $self->{taxes} }, { amount => $pat{taxes}->{$tax_chart_id},
1527 netamount => $pat{amounts}->{$amount_keys[0]}->{amount},
1531 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items_sorted}, @{$pat{items}};
1534 # get data for saving, printing, ..., that is not changed in the form
1536 # Only cvars for now.
1537 sub get_unalterable_data {
1540 foreach my $item (@{ $self->order->items }) {
1541 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1542 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1543 foreach my $var (@{ $item->cvars_by_config }) {
1544 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1546 $item->parse_custom_variable_values;
1552 # And remove related files in the spool directory
1557 my $db = $self->order->db;
1559 $db->with_transaction(
1561 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1562 $self->order->delete;
1563 my $spool = $::lx_office_conf{paths}->{spool};
1564 unlink map { "$spool/$_" } @spoolfiles if $spool;
1567 }) || push(@{$errors}, $db->error);
1574 # And delete items that are deleted in the form.
1579 my $db = $self->order->db;
1581 $db->with_transaction(sub {
1582 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1583 $self->order->save(cascade => 1);
1586 if ($::form->{converted_from_oe_id}) {
1587 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1588 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1589 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1590 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
1591 $src->link_to_record($self->order);
1593 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1595 foreach (@{ $self->order->items_sorted }) {
1596 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1598 SL::DB::RecordLink->new(from_table => 'orderitems',
1599 from_id => $from_id,
1600 to_table => 'orderitems',
1608 }) || push(@{$errors}, $db->error);
1613 sub workflow_sales_or_purchase_order {
1617 my $errors = $self->save();
1619 if (scalar @{ $errors }) {
1620 $self->js->flash('error', $_) foreach @{ $errors };
1621 return $self->js->render();
1624 my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type()
1625 : $::form->{type} eq request_quotation_type() ? purchase_order_type()
1626 : $::form->{type} eq purchase_order_type() ? sales_order_type()
1627 : $::form->{type} eq sales_order_type() ? purchase_order_type()
1630 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1631 $self->{converted_from_oe_id} = delete $::form->{id};
1633 # set item ids to new fake id, to identify them as new items
1634 foreach my $item (@{$self->order->items_sorted}) {
1635 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1639 $::form->{type} = $destination_type;
1640 $self->type($self->init_type);
1641 $self->cv ($self->init_cv);
1645 $self->get_unalterable_data();
1646 $self->pre_render();
1648 # trigger rendering values for second row/longdescription as hidden,
1649 # because they are loaded only on demand. So we need to keep the values
1651 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1652 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
1656 title => $self->get_title_for('edit'),
1657 %{$self->{template_args}}
1665 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1666 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1667 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1670 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1673 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1675 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1676 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1677 $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
1678 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1679 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1681 my $print_form = Form->new('');
1682 $print_form->{type} = $self->type;
1683 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1684 $print_form->{languages} = SL::DB::Manager::Language->get_all_sorted;
1685 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1686 form => $print_form,
1687 options => {dialog_name_prefix => 'print_options.',
1691 no_opendocument => 0,
1695 foreach my $item (@{$self->order->orderitems}) {
1696 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1697 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1698 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1701 if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
1702 # calculate shipped qtys here to prevent calling calculate for every item via the items method
1703 SL::Helper::ShippedQty->new->calculate($self->order)->write_to_objects;
1706 if ($self->order->number && $::instance_conf->get_webdav) {
1707 my $webdav = SL::Webdav->new(
1708 type => $self->type,
1709 number => $self->order->number,
1711 my @all_objects = $webdav->get_all_objects;
1712 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1714 link => File::Spec->catfile($_->full_filedescriptor),
1718 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1720 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery edit_periodic_invoices_config calculate_qty);
1721 $self->setup_edit_action_bar;
1724 sub setup_edit_action_bar {
1725 my ($self, %params) = @_;
1727 my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
1728 || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
1729 || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
1731 for my $bar ($::request->layout->get('actionbar')) {
1736 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1737 $::instance_conf->get_order_warn_no_deliverydate,
1739 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1743 call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1744 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1745 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1747 ], # end of combobox "Save"
1754 t8('Save and Sales Order'),
1755 submit => [ '#order_form', { action => "Order/sales_order" } ],
1756 only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
1757 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1760 t8('Save and Purchase Order'),
1761 submit => [ '#order_form', { action => "Order/purchase_order" } ],
1762 only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
1763 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1766 t8('Save and Delivery Order'),
1767 call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1768 $::instance_conf->get_order_warn_no_deliverydate,
1770 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1771 only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type()))
1774 t8('Save and Invoice'),
1775 call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1776 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1779 t8('Save and AP Transaction'),
1780 call => [ 'kivi.Order.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
1781 only_if => (any { $self->type eq $_ } (purchase_order_type()))
1784 ], # end of combobox "Workflow"
1791 t8('Save and print'),
1792 call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts ],
1795 t8('Save and E-mail'),
1796 call => [ 'kivi.Order.email', $::instance_conf->get_order_warn_duplicate_parts ],
1799 t8('Download attachments of all parts'),
1800 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1801 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1802 only_if => $::instance_conf->get_doc_storage,
1804 ], # end of combobox "Export"
1808 call => [ 'kivi.Order.delete_order' ],
1809 confirm => $::locale->text('Do you really want to delete this object?'),
1810 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1811 only_if => $deletion_allowed,
1818 my ($order, $pdf_ref, $params) = @_;
1822 my $print_form = Form->new('');
1823 $print_form->{type} = $order->type;
1824 $print_form->{formname} = $params->{formname} || $order->type;
1825 $print_form->{format} = $params->{format} || 'pdf';
1826 $print_form->{media} = $params->{media} || 'file';
1827 $print_form->{groupitems} = $params->{groupitems};
1828 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1830 $order->language($params->{language});
1831 $order->flatten_to_form($print_form, format_amounts => 1);
1835 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
1836 $template_ext = 'odt';
1837 $template_type = 'OpenDocument';
1840 # search for the template
1841 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1842 name => $print_form->{formname},
1843 extension => $template_ext,
1844 email => $print_form->{media} eq 'email',
1845 language => $params->{language},
1846 printer_id => $print_form->{printer_id}, # todo
1849 if (!defined $template_file) {
1850 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);
1853 return @errors if scalar @errors;
1855 $print_form->throw_on_error(sub {
1857 $print_form->prepare_for_printing;
1859 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1860 format => $print_form->{format},
1861 template_type => $template_type,
1862 template => $template_file,
1863 variables => $print_form,
1864 variable_content_types => {
1865 longdescription => 'html',
1866 partnotes => 'html',
1871 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
1877 sub get_files_for_email_dialog {
1880 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
1882 return %files if !$::instance_conf->get_doc_storage;
1884 if ($self->order->id) {
1885 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
1886 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
1887 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
1891 uniq_by { $_->{id} }
1893 +{ id => $_->part->id,
1894 partnumber => $_->part->partnumber }
1895 } @{$self->order->items_sorted};
1897 foreach my $part (@parts) {
1898 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
1899 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
1902 foreach my $key (keys %files) {
1903 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
1909 sub make_periodic_invoices_config_from_yaml {
1910 my ($yaml_config) = @_;
1912 return if !$yaml_config;
1913 my $attr = SL::YAML::Load($yaml_config);
1914 return if 'HASH' ne ref $attr;
1915 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
1919 sub get_periodic_invoices_status {
1920 my ($self, $config) = @_;
1922 return if $self->type ne sales_order_type();
1923 return t8('not configured') if !$config;
1925 my $active = ('HASH' eq ref $config) ? $config->{active}
1926 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
1927 : die "Cannot get status of periodic invoices config";
1929 return $active ? t8('active') : t8('inactive');
1933 my ($self, $action) = @_;
1935 return '' if none { lc($action)} qw(add edit);
1938 # $::locale->text("Add Sales Order");
1939 # $::locale->text("Add Purchase Order");
1940 # $::locale->text("Add Quotation");
1941 # $::locale->text("Add Request for Quotation");
1942 # $::locale->text("Edit Sales Order");
1943 # $::locale->text("Edit Purchase Order");
1944 # $::locale->text("Edit Quotation");
1945 # $::locale->text("Edit Request for Quotation");
1947 $action = ucfirst(lc($action));
1948 return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order")
1949 : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order")
1950 : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation")
1951 : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
1955 sub get_item_cvpartnumber {
1956 my ($self, $item) = @_;
1958 if ($self->cv eq 'vendor') {
1959 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
1960 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
1961 } elsif ($self->cv eq 'customer') {
1962 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
1963 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
1967 sub sales_order_type {
1971 sub purchase_order_type {
1975 sub sales_quotation_type {
1979 sub request_quotation_type {
1980 'request_quotation';
1984 return $_[0]->type eq sales_order_type() ? 'ordnumber'
1985 : $_[0]->type eq purchase_order_type() ? 'ordnumber'
1986 : $_[0]->type eq sales_quotation_type() ? 'quonumber'
1987 : $_[0]->type eq request_quotation_type() ? 'quonumber'
1999 SL::Controller::Order - controller for orders
2003 This is a new form to enter orders, completely rewritten with the use
2004 of controller and java script techniques.
2006 The aim is to provide the user a better experience and a faster workflow. Also
2007 the code should be more readable, more reliable and better to maintain.
2015 One input row, so that input happens every time at the same place.
2019 Use of pickers where possible.
2023 Possibility to enter more than one item at once.
2027 Item list in a scrollable area, so that the workflow buttons stay at
2032 Reordering item rows with drag and drop is possible. Sorting item rows is
2033 possible (by partnumber, description, qty, sellprice and discount for now).
2037 No C<update> is necessary. All entries and calculations are managed
2038 with ajax-calls and the page only reloads on C<save>.
2042 User can see changes immediately, because of the use of java script
2053 =item * C<SL/Controller/Order.pm>
2057 =item * C<template/webpages/order/form.html>
2061 =item * C<template/webpages/order/tabs/basic_data.html>
2063 Main tab for basic_data.
2065 This is the only tab here for now. "linked records" and "webdav" tabs are
2066 reused from generic code.
2070 =item * C<template/webpages/order/tabs/_business_info_row.html>
2072 For displaying information on business type
2074 =item * C<template/webpages/order/tabs/_item_input.html>
2076 The input line for items
2078 =item * C<template/webpages/order/tabs/_row.html>
2080 One row for already entered items
2082 =item * C<template/webpages/order/tabs/_tax_row.html>
2084 Displaying tax information
2086 =item * C<template/webpages/order/tabs/_multi_items_dialog.html>
2088 Dialog for entering more than one item at once
2090 =item * C<template/webpages/order/tabs/_multi_items_result.html>
2092 Results for the filter in the multi items dialog
2094 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
2096 Dialog for selecting price and discount sources
2100 =item * C<js/kivi.Order.js>
2102 java script functions
2114 =item * credit limit
2116 =item * more workflows (quotation, rfq)
2118 =item * price sources: little symbols showing better price / better discount
2120 =item * select units in input row?
2122 =item * custom shipto address
2124 =item * check for direct delivery (workflow sales order -> purchase order)
2126 =item * language / part translations
2128 =item * access rights
2130 =item * display weights
2136 =item * optional client/user behaviour
2138 (transactions has to be set - department has to be set -
2139 force project if enabled in client config - transport cost reminder)
2143 =head1 KNOWN BUGS AND CAVEATS
2149 Customer discount is not displayed as a valid discount in price source popup
2150 (this might be a bug in price sources)
2152 (I cannot reproduce this (Bernd))
2156 No indication that <shift>-up/down expands/collapses second row.
2160 Inline creation of parts is not currently supported
2164 Table header is not sticky in the scrolling area.
2168 Sorting does not include C<position>, neither does reordering.
2170 This behavior was implemented intentionally. But we can discuss, which behavior
2171 should be implemented.
2175 C<show_multi_items_dialog> does not use the currently inserted string for
2180 The language selected in print or email dialog is not saved when the order is saved.
2184 =head1 To discuss / Nice to have
2190 How to expand/collapse second row. Now it can be done clicking the icon or
2195 Possibility to change longdescription in input row?
2199 Possibility to select PriceSources in input row?
2203 This controller uses a (changed) copy of the template for the PriceSource
2204 dialog. Maybe there could be used one code source.
2208 Rounding-differences between this controller (PriceTaxCalculator) and the old
2209 form. This is not only a problem here, but also in all parts using the PTC.
2210 There exists a ticket and a patch. This patch should be testet.
2214 An indicator, if the actual inputs are saved (like in an
2215 editor or on text processing application).
2219 A warning when leaving the page without saveing unchanged inputs.
2226 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>