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',
800 ->append('#row_table_id', $row_as_html);
802 if ( $item->part->is_assortment ) {
803 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
804 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
805 my $attr = { parts_id => $assortment_item->parts_id,
806 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
807 unit => $assortment_item->unit,
808 description => $assortment_item->part->description,
810 my $item = new_item($self->order, $attr);
812 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
813 $item->discount(1) unless $assortment_item->charge;
815 $self->order->add_items( $item );
817 $self->get_item_cvpartnumber($item);
818 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
819 my $row_as_html = $self->p->render('order/tabs/_row',
825 ->append('#row_table_id', $row_as_html);
830 ->val('.add_item_input', '')
831 ->run('kivi.Order.init_row_handlers')
832 ->run('kivi.Order.row_table_scroll_down')
833 ->run('kivi.Order.renumber_positions')
834 ->focus('#add_item_parts_id_name');
836 $self->js_redisplay_amounts_and_taxes;
840 # open the dialog for entering multiple items at once
841 sub action_show_multi_items_dialog {
842 $_[0]->render('order/tabs/_multi_items_dialog', { layout => 0 },
843 all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
846 # update the filter results in the multi item dialog
847 sub action_multi_items_update_result {
850 $::form->{multi_items}->{filter}->{obsolete} = 0;
852 my $count = $_[0]->multi_items_models->count;
855 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
856 $_[0]->render($text, { layout => 0 });
857 } elsif ($count > $max_count) {
858 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
859 $_[0]->render($text, { layout => 0 });
861 my $multi_items = $_[0]->multi_items_models->get;
862 $_[0]->render('order/tabs/_multi_items_result', { layout => 0 },
863 multi_items => $multi_items);
867 # add item rows for multiple items at once
868 sub action_add_multi_items {
871 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_multi_items} };
872 return $self->js->render() unless scalar @form_attr;
875 foreach my $attr (@form_attr) {
876 my $item = new_item($self->order, $attr);
878 if ( $item->part->is_assortment ) {
879 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
880 my $attr = { parts_id => $assortment_item->parts_id,
881 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
882 unit => $assortment_item->unit,
883 description => $assortment_item->part->description,
885 my $item = new_item($self->order, $attr);
887 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
888 $item->discount(1) unless $assortment_item->charge;
893 $self->order->add_items(@items);
897 foreach my $item (@items) {
898 $self->get_item_cvpartnumber($item);
899 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
900 my $row_as_html = $self->p->render('order/tabs/_row',
906 $self->js->append('#row_table_id', $row_as_html);
910 ->run('kivi.Order.close_multi_items_dialog')
911 ->run('kivi.Order.init_row_handlers')
912 ->run('kivi.Order.row_table_scroll_down')
913 ->run('kivi.Order.renumber_positions')
914 ->focus('#add_item_parts_id_name');
916 $self->js_redisplay_amounts_and_taxes;
920 # recalculate all linetotals, amounts and taxes and redisplay them
921 sub action_recalc_amounts_and_taxes {
926 $self->js_redisplay_line_values;
927 $self->js_redisplay_amounts_and_taxes;
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 # get the longdescription for an item if the dialog to enter/change the
977 # longdescription was opened and the longdescription is empty
979 # If this item is new, get the longdescription from Part.
980 # Otherwise get it from OrderItem.
981 sub action_get_item_longdescription {
984 if ($::form->{item_id}) {
985 $longdescription = SL::DB::OrderItem->new(id => $::form->{item_id})->load->longdescription;
986 } elsif ($::form->{parts_id}) {
987 $longdescription = SL::DB::Part->new(id => $::form->{parts_id})->load->notes;
989 $_[0]->render(\ $longdescription, { type => 'text' });
992 # load the second row for one or more items
994 # This action gets the html code for all items second rows by rendering a template for
995 # the second row and sets the html code via client js.
996 sub action_load_second_rows {
999 $self->recalc() if $self->order->is_sales; # for margin calculation
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];
1005 $self->js_load_second_row($item, $item_id, 0);
1008 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
1010 $self->js->render();
1013 # update description, notes and sellprice from master data
1014 sub action_update_row_from_master_data {
1017 foreach my $item_id (@{ $::form->{item_ids} }) {
1018 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1019 my $item = $self->order->items_sorted->[$idx];
1021 $item->description($item->part->description);
1022 $item->longdescription($item->part->notes);
1024 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1027 if ($item->part->is_assortment) {
1028 # add assortment items with price 0, as the components carry the price
1029 $price_src = $price_source->price_from_source("");
1030 $price_src->price(0);
1032 $price_src = $price_source->best_price
1033 ? $price_source->best_price
1034 : $price_source->price_from_source("");
1035 $price_src->price(0) if !$price_source->best_price;
1038 $item->sellprice($price_src->price);
1039 $item->active_price_source($price_src);
1042 ->run('kivi.Order.update_sellprice', $item_id, $item->sellprice_as_number)
1043 ->val('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
1044 ->val('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
1048 $self->js_redisplay_line_values;
1049 $self->js_redisplay_amounts_and_taxes;
1051 $self->js->render();
1054 sub js_load_second_row {
1055 my ($self, $item, $item_id, $do_parse) = @_;
1058 # Parse values from form (they are formated while rendering (template)).
1059 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1060 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1061 foreach my $var (@{ $item->cvars_by_config }) {
1062 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1064 $item->parse_custom_variable_values;
1067 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1070 ->html('#second_row_' . $item_id, $row_as_html)
1071 ->data('#second_row_' . $item_id, 'loaded', 1);
1074 sub js_redisplay_line_values {
1077 my $is_sales = $self->order->is_sales;
1079 # sales orders with margins
1084 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1085 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1086 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1087 ]} @{ $self->order->items_sorted };
1091 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1092 ]} @{ $self->order->items_sorted };
1096 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1099 sub js_redisplay_amounts_and_taxes {
1102 if (scalar @{ $self->{taxes} }) {
1103 $self->js->show('#taxincluded_row_id');
1105 $self->js->hide('#taxincluded_row_id');
1108 if ($self->order->taxincluded) {
1109 $self->js->hide('#subtotal_row_id');
1111 $self->js->show('#subtotal_row_id');
1114 if ($self->order->is_sales) {
1115 my $is_neg = $self->order->marge_total < 0;
1117 ->html('#marge_total_id', $::form->format_amount(\%::myconfig, $self->order->marge_total, 2))
1118 ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
1119 ->action_if( $is_neg, 'addClass', '#marge_total_id', 'plus0')
1120 ->action_if( $is_neg, 'addClass', '#marge_percent_id', 'plus0')
1121 ->action_if( $is_neg, 'addClass', '#marge_percent_sign_id', 'plus0')
1122 ->action_if(!$is_neg, 'removeClass', '#marge_total_id', 'plus0')
1123 ->action_if(!$is_neg, 'removeClass', '#marge_percent_id', 'plus0')
1124 ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
1128 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
1129 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
1130 ->remove('.tax_row')
1131 ->insertBefore($self->build_tax_rows, '#amount_row_id');
1134 sub js_redisplay_cvpartnumbers {
1137 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1139 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1142 ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
1145 sub js_reset_order_and_item_ids_after_save {
1149 ->val('#id', $self->order->id)
1150 ->val('#converted_from_oe_id', '')
1151 ->val('#order_' . $self->nr_key(), $self->order->number);
1154 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1155 next if !$self->order->items_sorted->[$idx]->id;
1156 next if $form_item_id !~ m{^new};
1158 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1159 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1160 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1164 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1171 sub init_valid_types {
1172 [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
1178 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1179 die "Not a valid type for order";
1182 $self->type($::form->{type});
1188 my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer'
1189 : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
1190 : die "Not a valid type for order";
1195 sub init_search_cvpartnumber {
1198 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1199 my $search_cvpartnumber;
1200 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1201 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1203 return $search_cvpartnumber;
1206 sub init_show_update_button {
1209 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1220 # model used to filter/display the parts in the multi-items dialog
1221 sub init_multi_items_models {
1222 SL::Controller::Helper::GetModels->new(
1223 controller => $_[0],
1225 with_objects => [ qw(unit_obj) ],
1226 disable_plugin => 'paginated',
1227 source => $::form->{multi_items},
1233 partnumber => t8('Partnumber'),
1234 description => t8('Description')}
1238 sub init_all_price_factors {
1239 SL::DB::Manager::PriceFactor->get_all;
1245 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
1247 my $right = $right_for->{ $self->type };
1248 $right ||= 'DOES_NOT_EXIST';
1250 $::auth->assert($right);
1253 # build the selection box for contacts
1255 # Needed, if customer/vendor changed.
1256 sub build_contact_select {
1259 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1260 value_key => 'cp_id',
1261 title_key => 'full_name_dep',
1262 default => $self->order->cp_id,
1264 style => 'width: 300px',
1268 # build the selection box for shiptos
1270 # Needed, if customer/vendor changed.
1271 sub build_shipto_select {
1274 select_tag('order.shipto_id', [ $self->order->{$self->cv}->shipto ],
1275 value_key => 'shipto_id',
1276 title_key => 'displayable_id',
1277 default => $self->order->shipto_id,
1279 style => 'width: 300px',
1283 # render the info line for business
1285 # Needed, if customer/vendor changed.
1286 sub build_business_info_row
1288 $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
1291 # build the rows for displaying taxes
1293 # Called if amounts where recalculated and redisplayed.
1294 sub build_tax_rows {
1298 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
1299 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
1301 return $rows_as_html;
1305 sub render_price_dialog {
1306 my ($self, $record_item) = @_;
1308 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1312 'kivi.io.price_chooser_dialog',
1313 t8('Available Prices'),
1314 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1319 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1320 # $self->js->show('#dialog_flash_error');
1329 return if !$::form->{id};
1331 $self->order(SL::DB::Order->new(id => $::form->{id})->load);
1334 # load or create a new order object
1336 # And assign changes from the form to this object.
1337 # If the order is loaded from db, check if items are deleted in the form,
1338 # remove them form the object and collect them for removing from db on saving.
1339 # Then create/update items from form (via make_item) and add them.
1343 # add_items adds items to an order with no items for saving, but they cannot
1344 # be retrieved via items until the order is saved. Adding empty items to new
1345 # order here solves this problem.
1347 $order = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1348 $order ||= SL::DB::Order->new(orderitems => [],
1349 quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())));
1351 my $cv_id_method = $self->cv . '_id';
1352 if (!$::form->{id} && $::form->{$cv_id_method}) {
1353 $order->$cv_id_method($::form->{$cv_id_method});
1354 setup_order_from_cv($order);
1357 my $form_orderitems = delete $::form->{order}->{orderitems};
1358 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1360 $order->assign_attributes(%{$::form->{order}});
1362 if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) {
1363 my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
1364 $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
1367 # remove deleted items
1368 $self->item_ids_to_delete([]);
1369 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1370 my $item = $order->orderitems->[$idx];
1371 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1372 splice @{$order->orderitems}, $idx, 1;
1373 push @{$self->item_ids_to_delete}, $item->id;
1379 foreach my $form_attr (@{$form_orderitems}) {
1380 my $item = make_item($order, $form_attr);
1381 $item->position($pos);
1385 $order->add_items(grep {!$_->id} @items);
1390 # create or update items from form
1392 # Make item objects from form values. For items already existing read from db.
1393 # Create a new item else. And assign attributes.
1395 my ($record, $attr) = @_;
1398 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1400 my $is_new = !$item;
1402 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1403 # they cannot be retrieved via custom_variables until the order/orderitem is
1404 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1405 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1407 $item->assign_attributes(%$attr);
1408 $item->longdescription($item->part->notes) if $is_new && !defined $attr->{longdescription};
1409 $item->project_id($record->globalproject_id) if $is_new && !defined $attr->{project_id};
1410 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if $is_new && !defined $attr->{lastcost_as_number};
1417 # This is used to add one item
1419 my ($record, $attr) = @_;
1421 my $item = SL::DB::OrderItem->new;
1423 # Remove attributes where the user left or set the inputs empty.
1424 # So these attributes will be undefined and we can distinguish them
1425 # from zero later on.
1426 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1427 delete $attr->{$_} if $attr->{$_} eq '';
1430 $item->assign_attributes(%$attr);
1432 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1433 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1435 $item->unit($part->unit) if !$item->unit;
1438 if ( $part->is_assortment ) {
1439 # add assortment items with price 0, as the components carry the price
1440 $price_src = $price_source->price_from_source("");
1441 $price_src->price(0);
1442 } elsif (defined $item->sellprice) {
1443 $price_src = $price_source->price_from_source("");
1444 $price_src->price($item->sellprice);
1446 $price_src = $price_source->best_price
1447 ? $price_source->best_price
1448 : $price_source->price_from_source("");
1449 $price_src->price(0) if !$price_source->best_price;
1453 if (defined $item->discount) {
1454 $discount_src = $price_source->discount_from_source("");
1455 $discount_src->discount($item->discount);
1457 $discount_src = $price_source->best_discount
1458 ? $price_source->best_discount
1459 : $price_source->discount_from_source("");
1460 $discount_src->discount(0) if !$price_source->best_discount;
1464 $new_attr{part} = $part;
1465 $new_attr{description} = $part->description if ! $item->description;
1466 $new_attr{qty} = 1.0 if ! $item->qty;
1467 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1468 $new_attr{sellprice} = $price_src->price;
1469 $new_attr{discount} = $discount_src->discount;
1470 $new_attr{active_price_source} = $price_src;
1471 $new_attr{active_discount_source} = $discount_src;
1472 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1473 $new_attr{project_id} = $record->globalproject_id;
1474 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1476 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1477 # they cannot be retrieved via custom_variables until the order/orderitem is
1478 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1479 $new_attr{custom_variables} = [];
1481 $item->assign_attributes(%new_attr);
1486 sub setup_order_from_cv {
1489 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id));
1491 $order->intnotes($order->customervendor->notes);
1493 if ($order->is_sales) {
1494 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1495 $order->taxincluded(defined($order->customer->taxincluded_checked)
1496 ? $order->customer->taxincluded_checked
1497 : $::myconfig{taxincluded_checked});
1502 # recalculate prices and taxes
1504 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1508 # bb: todo: currency later
1509 $self->order->currency_id($::instance_conf->get_currency_id());
1511 my %pat = $self->order->calculate_prices_and_taxes();
1512 $self->{taxes} = [];
1513 foreach my $tax_chart_id (keys %{ $pat{taxes} }) {
1514 my $tax = SL::DB::Manager::Tax->find_by(chart_id => $tax_chart_id);
1516 my @amount_keys = grep { $pat{amounts}->{$_}->{tax_id} == $tax->id } keys %{ $pat{amounts} };
1517 push(@{ $self->{taxes} }, { amount => $pat{taxes}->{$tax_chart_id},
1518 netamount => $pat{amounts}->{$amount_keys[0]}->{amount},
1522 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items_sorted}, @{$pat{items}};
1525 # get data for saving, printing, ..., that is not changed in the form
1527 # Only cvars for now.
1528 sub get_unalterable_data {
1531 foreach my $item (@{ $self->order->items }) {
1532 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1533 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1534 foreach my $var (@{ $item->cvars_by_config }) {
1535 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1537 $item->parse_custom_variable_values;
1543 # And remove related files in the spool directory
1548 my $db = $self->order->db;
1550 $db->with_transaction(
1552 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1553 $self->order->delete;
1554 my $spool = $::lx_office_conf{paths}->{spool};
1555 unlink map { "$spool/$_" } @spoolfiles if $spool;
1558 }) || push(@{$errors}, $db->error);
1565 # And delete items that are deleted in the form.
1570 my $db = $self->order->db;
1572 $db->with_transaction(sub {
1573 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1574 $self->order->save(cascade => 1);
1577 if ($::form->{converted_from_oe_id}) {
1578 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1579 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1580 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1581 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
1582 $src->link_to_record($self->order);
1584 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1586 foreach (@{ $self->order->items_sorted }) {
1587 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1589 SL::DB::RecordLink->new(from_table => 'orderitems',
1590 from_id => $from_id,
1591 to_table => 'orderitems',
1599 }) || push(@{$errors}, $db->error);
1604 sub workflow_sales_or_purchase_order {
1608 my $errors = $self->save();
1610 if (scalar @{ $errors }) {
1611 $self->js->flash('error', $_) foreach @{ $errors };
1612 return $self->js->render();
1615 my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type()
1616 : $::form->{type} eq request_quotation_type() ? purchase_order_type()
1617 : $::form->{type} eq purchase_order_type() ? sales_order_type()
1618 : $::form->{type} eq sales_order_type() ? purchase_order_type()
1621 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1622 $self->{converted_from_oe_id} = delete $::form->{id};
1624 # set item ids to new fake id, to identify them as new items
1625 foreach my $item (@{$self->order->items_sorted}) {
1626 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1630 $::form->{type} = $destination_type;
1631 $self->type($self->init_type);
1632 $self->cv ($self->init_cv);
1636 $self->get_unalterable_data();
1637 $self->pre_render();
1639 # trigger rendering values for second row/longdescription as hidden,
1640 # because they are loaded only on demand. So we need to keep the values
1642 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1643 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
1647 title => $self->get_title_for('edit'),
1648 %{$self->{template_args}}
1656 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1657 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1658 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1661 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1664 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1666 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1667 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1668 $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
1669 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1670 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1672 my $print_form = Form->new('');
1673 $print_form->{type} = $self->type;
1674 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1675 $print_form->{languages} = SL::DB::Manager::Language->get_all_sorted;
1676 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1677 form => $print_form,
1678 options => {dialog_name_prefix => 'print_options.',
1682 no_opendocument => 0,
1686 foreach my $item (@{$self->order->orderitems}) {
1687 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1688 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1689 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1692 if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
1693 # calculate shipped qtys here to prevent calling calculate for every item via the items method
1694 SL::Helper::ShippedQty->new->calculate($self->order)->write_to_objects;
1697 if ($self->order->number && $::instance_conf->get_webdav) {
1698 my $webdav = SL::Webdav->new(
1699 type => $self->type,
1700 number => $self->order->number,
1702 my @all_objects = $webdav->get_all_objects;
1703 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1705 link => File::Spec->catfile($_->full_filedescriptor),
1709 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1711 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery edit_periodic_invoices_config calculate_qty);
1712 $self->setup_edit_action_bar;
1715 sub setup_edit_action_bar {
1716 my ($self, %params) = @_;
1718 my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
1719 || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
1720 || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
1722 for my $bar ($::request->layout->get('actionbar')) {
1727 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1728 $::instance_conf->get_order_warn_no_deliverydate,
1730 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1734 call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1735 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1736 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1738 ], # end of combobox "Save"
1745 t8('Save and Sales Order'),
1746 submit => [ '#order_form', { action => "Order/sales_order" } ],
1747 only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
1748 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1751 t8('Save and Purchase Order'),
1752 submit => [ '#order_form', { action => "Order/purchase_order" } ],
1753 only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
1754 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1757 t8('Save and Delivery Order'),
1758 call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1759 $::instance_conf->get_order_warn_no_deliverydate,
1761 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1762 only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type()))
1765 t8('Save and Invoice'),
1766 call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1767 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1770 t8('Save and AP Transaction'),
1771 call => [ 'kivi.Order.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
1772 only_if => (any { $self->type eq $_ } (purchase_order_type()))
1775 ], # end of combobox "Workflow"
1782 t8('Save and print'),
1783 call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts ],
1786 t8('Save and E-mail'),
1787 call => [ 'kivi.Order.email', $::instance_conf->get_order_warn_duplicate_parts ],
1790 t8('Download attachments of all parts'),
1791 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1792 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1793 only_if => $::instance_conf->get_doc_storage,
1795 ], # end of combobox "Export"
1799 call => [ 'kivi.Order.delete_order' ],
1800 confirm => $::locale->text('Do you really want to delete this object?'),
1801 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1802 only_if => $deletion_allowed,
1809 my ($order, $pdf_ref, $params) = @_;
1813 my $print_form = Form->new('');
1814 $print_form->{type} = $order->type;
1815 $print_form->{formname} = $params->{formname} || $order->type;
1816 $print_form->{format} = $params->{format} || 'pdf';
1817 $print_form->{media} = $params->{media} || 'file';
1818 $print_form->{groupitems} = $params->{groupitems};
1819 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1821 $order->language($params->{language});
1822 $order->flatten_to_form($print_form, format_amounts => 1);
1826 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
1827 $template_ext = 'odt';
1828 $template_type = 'OpenDocument';
1831 # search for the template
1832 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1833 name => $print_form->{formname},
1834 extension => $template_ext,
1835 email => $print_form->{media} eq 'email',
1836 language => $params->{language},
1837 printer_id => $print_form->{printer_id}, # todo
1840 if (!defined $template_file) {
1841 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);
1844 return @errors if scalar @errors;
1846 $print_form->throw_on_error(sub {
1848 $print_form->prepare_for_printing;
1850 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1851 format => $print_form->{format},
1852 template_type => $template_type,
1853 template => $template_file,
1854 variables => $print_form,
1855 variable_content_types => {
1856 longdescription => 'html',
1857 partnotes => 'html',
1862 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
1868 sub get_files_for_email_dialog {
1871 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
1873 return %files if !$::instance_conf->get_doc_storage;
1875 if ($self->order->id) {
1876 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
1877 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
1878 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
1882 uniq_by { $_->{id} }
1884 +{ id => $_->part->id,
1885 partnumber => $_->part->partnumber }
1886 } @{$self->order->items_sorted};
1888 foreach my $part (@parts) {
1889 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
1890 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
1893 foreach my $key (keys %files) {
1894 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
1900 sub make_periodic_invoices_config_from_yaml {
1901 my ($yaml_config) = @_;
1903 return if !$yaml_config;
1904 my $attr = SL::YAML::Load($yaml_config);
1905 return if 'HASH' ne ref $attr;
1906 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
1910 sub get_periodic_invoices_status {
1911 my ($self, $config) = @_;
1913 return if $self->type ne sales_order_type();
1914 return t8('not configured') if !$config;
1916 my $active = ('HASH' eq ref $config) ? $config->{active}
1917 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
1918 : die "Cannot get status of periodic invoices config";
1920 return $active ? t8('active') : t8('inactive');
1924 my ($self, $action) = @_;
1926 return '' if none { lc($action)} qw(add edit);
1929 # $::locale->text("Add Sales Order");
1930 # $::locale->text("Add Purchase Order");
1931 # $::locale->text("Add Quotation");
1932 # $::locale->text("Add Request for Quotation");
1933 # $::locale->text("Edit Sales Order");
1934 # $::locale->text("Edit Purchase Order");
1935 # $::locale->text("Edit Quotation");
1936 # $::locale->text("Edit Request for Quotation");
1938 $action = ucfirst(lc($action));
1939 return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order")
1940 : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order")
1941 : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation")
1942 : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
1946 sub get_item_cvpartnumber {
1947 my ($self, $item) = @_;
1949 if ($self->cv eq 'vendor') {
1950 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
1951 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
1952 } elsif ($self->cv eq 'customer') {
1953 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
1954 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
1958 sub sales_order_type {
1962 sub purchase_order_type {
1966 sub sales_quotation_type {
1970 sub request_quotation_type {
1971 'request_quotation';
1975 return $_[0]->type eq sales_order_type() ? 'ordnumber'
1976 : $_[0]->type eq purchase_order_type() ? 'ordnumber'
1977 : $_[0]->type eq sales_quotation_type() ? 'quonumber'
1978 : $_[0]->type eq request_quotation_type() ? 'quonumber'
1990 SL::Controller::Order - controller for orders
1994 This is a new form to enter orders, completely rewritten with the use
1995 of controller and java script techniques.
1997 The aim is to provide the user a better experience and a faster workflow. Also
1998 the code should be more readable, more reliable and better to maintain.
2006 One input row, so that input happens every time at the same place.
2010 Use of pickers where possible.
2014 Possibility to enter more than one item at once.
2018 Item list in a scrollable area, so that the workflow buttons stay at
2023 Reordering item rows with drag and drop is possible. Sorting item rows is
2024 possible (by partnumber, description, qty, sellprice and discount for now).
2028 No C<update> is necessary. All entries and calculations are managed
2029 with ajax-calls and the page only reloads on C<save>.
2033 User can see changes immediately, because of the use of java script
2044 =item * C<SL/Controller/Order.pm>
2048 =item * C<template/webpages/order/form.html>
2052 =item * C<template/webpages/order/tabs/basic_data.html>
2054 Main tab for basic_data.
2056 This is the only tab here for now. "linked records" and "webdav" tabs are
2057 reused from generic code.
2061 =item * C<template/webpages/order/tabs/_business_info_row.html>
2063 For displaying information on business type
2065 =item * C<template/webpages/order/tabs/_item_input.html>
2067 The input line for items
2069 =item * C<template/webpages/order/tabs/_row.html>
2071 One row for already entered items
2073 =item * C<template/webpages/order/tabs/_tax_row.html>
2075 Displaying tax information
2077 =item * C<template/webpages/order/tabs/_multi_items_dialog.html>
2079 Dialog for entering more than one item at once
2081 =item * C<template/webpages/order/tabs/_multi_items_result.html>
2083 Results for the filter in the multi items dialog
2085 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
2087 Dialog for selecting price and discount sources
2091 =item * C<js/kivi.Order.js>
2093 java script functions
2105 =item * credit limit
2107 =item * more workflows (quotation, rfq)
2109 =item * price sources: little symbols showing better price / better discount
2111 =item * select units in input row?
2113 =item * custom shipto address
2115 =item * check for direct delivery (workflow sales order -> purchase order)
2117 =item * language / part translations
2119 =item * access rights
2121 =item * display weights
2127 =item * optional client/user behaviour
2129 (transactions has to be set - department has to be set -
2130 force project if enabled in client config - transport cost reminder)
2134 =head1 KNOWN BUGS AND CAVEATS
2140 Customer discount is not displayed as a valid discount in price source popup
2141 (this might be a bug in price sources)
2143 (I cannot reproduce this (Bernd))
2147 No indication that <shift>-up/down expands/collapses second row.
2151 Inline creation of parts is not currently supported
2155 Table header is not sticky in the scrolling area.
2159 Sorting does not include C<position>, neither does reordering.
2161 This behavior was implemented intentionally. But we can discuss, which behavior
2162 should be implemented.
2166 C<show_multi_items_dialog> does not use the currently inserted string for
2171 The language selected in print or email dialog is not saved when the order is saved.
2175 =head1 To discuss / Nice to have
2181 How to expand/collapse second row. Now it can be done clicking the icon or
2186 Possibility to change longdescription in input row?
2190 Possibility to select PriceSources in input row?
2194 This controller uses a (changed) copy of the template for the PriceSource
2195 dialog. Maybe there could be used one code source.
2199 Rounding-differences between this controller (PriceTaxCalculator) and the old
2200 form. This is not only a problem here, but also in all parts using the PTC.
2201 There exists a ticket and a patch. This patch should be testet.
2205 An indicator, if the actual inputs are saved (like in an
2206 editor or on text processing application).
2210 A warning when leaving the page without saveing unchanged inputs.
2217 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>