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;
13 use SL::Util qw(trim);
18 use SL::DB::PartsGroup;
21 use SL::DB::RecordLink;
23 use SL::Helper::CreatePDF qw(:all);
24 use SL::Helper::PrintOptions;
25 use SL::Helper::ShippedQty;
27 use SL::Controller::Helper::GetModels;
29 use List::Util qw(first);
30 use List::UtilsBy qw(sort_by uniq_by);
31 use List::MoreUtils qw(any none pairwise first_index);
32 use English qw(-no_match_vars);
36 use Rose::Object::MakeMethods::Generic
38 scalar => [ qw(item_ids_to_delete) ],
39 'scalar --get_set_init' => [ qw(order valid_types type cv p multi_items_models all_price_factors) ],
44 __PACKAGE__->run_before('check_auth');
46 __PACKAGE__->run_before('recalc',
47 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice print send_email) ]);
49 __PACKAGE__->run_before('get_unalterable_data',
50 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice print send_email) ]);
60 $self->order->transdate(DateTime->now_local());
61 my $extra_days = $self->type eq sales_quotation_type() ? $::instance_conf->get_reqdate_interval : 1;
62 $self->order->reqdate(DateTime->today_local->next_workday(extra_days => $extra_days)) if !$self->order->reqdate;
67 title => $self->get_title_for('add'),
68 %{$self->{template_args}}
72 # edit an existing order
80 # this is to edit an order from an unsaved order object
82 # set item ids to new fake id, to identify them as new items
83 foreach my $item (@{$self->order->items_sorted}) {
84 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
86 # trigger rendering values for second row/longdescription as hidden,
87 # because they are loaded only on demand. So we need to keep the values
89 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
90 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
97 title => $self->get_title_for('edit'),
98 %{$self->{template_args}}
102 # edit a collective order (consisting of one or more existing orders)
103 sub action_edit_collective {
107 my @multi_ids = map {
108 $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1}
109 } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form;
111 # fall back to add if no ids are given
112 if (scalar @multi_ids == 0) {
117 # fall back to save as new if only one id is given
118 if (scalar @multi_ids == 1) {
119 $self->order(SL::DB::Order->new(id => $multi_ids[0])->load);
120 $self->action_save_as_new();
124 # make new order from given orders
125 my @multi_orders = map { SL::DB::Order->new(id => $_)->load } @multi_ids;
126 $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders;
127 $self->order(SL::DB::Order->new_from_multi(\@multi_orders, sort_sources_by => 'transdate'));
129 $self->action_edit();
136 my $errors = $self->delete();
138 if (scalar @{ $errors }) {
139 $self->js->flash('error', $_) foreach @{ $errors };
140 return $self->js->render();
143 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been deleted')
144 : $self->type eq purchase_order_type() ? $::locale->text('The order has been deleted')
145 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been deleted')
146 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been deleted')
148 flash_later('info', $text);
150 my @redirect_params = (
155 $self->redirect_to(@redirect_params);
162 my $errors = $self->save();
164 if (scalar @{ $errors }) {
165 $self->js->flash('error', $_) foreach @{ $errors };
166 return $self->js->render();
169 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
170 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
171 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
172 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
174 flash_later('info', $text);
176 my @redirect_params = (
179 id => $self->order->id,
182 $self->redirect_to(@redirect_params);
185 # save the order as new document an open it for edit
186 sub action_save_as_new {
189 my $order = $self->order;
192 $self->js->flash('error', t8('This object has not been saved yet.'));
193 return $self->js->render();
196 # load order from db to check if values changed
197 my $saved_order = SL::DB::Order->new(id => $order->id)->load;
200 # Lets assign a new number if the user hasn't changed the previous one.
201 # If it has been changed manually then use it as-is.
202 $new_attrs{number} = (trim($order->number) eq $saved_order->number)
204 : trim($order->number);
206 # Clear transdate unless changed
207 $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
208 ? DateTime->today_local
211 # Set new reqdate unless changed
212 if ($order->reqdate == $saved_order->reqdate) {
213 my $extra_days = $self->type eq sales_quotation_type() ? $::instance_conf->get_reqdate_interval : 1;
214 $new_attrs{reqdate} = DateTime->today_local->next_workday(extra_days => $extra_days);
216 $new_attrs{reqdate} = $order->reqdate;
220 $new_attrs{employee} = SL::DB::Manager::Employee->current;
222 # Create new record from current one
223 $self->order(SL::DB::Order->new_from($order, destination_type => $order->type, attributes => \%new_attrs));
225 # no linked records on save as new
226 delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
229 $self->action_save();
234 # This is called if "print" is pressed in the print dialog.
235 # If PDF creation was requested and succeeded, the pdf is stored in a session
236 # file and the filename is stored as session value with an unique key. A
237 # javascript function with this key is then called. This function calls the
238 # download action below (action_download_pdf), which offers the file for
243 my $errors = $self->save();
245 if (scalar @{ $errors }) {
246 $self->js->flash('error', $_) foreach @{ $errors };
247 return $self->js->render();
250 $self->js->val('#id', $self->order->id)
251 ->val('#order_' . $self->nr_key(), $self->order->number);
253 my $format = $::form->{print_options}->{format};
254 my $media = $::form->{print_options}->{media};
255 my $formname = $::form->{print_options}->{formname};
256 my $copies = $::form->{print_options}->{copies};
257 my $groupitems = $::form->{print_options}->{groupitems};
259 # only pdf and opendocument by now
260 if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf)) {
261 return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
264 # only screen or printer by now
265 if (none { $media eq $_ } qw(screen printer)) {
266 return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
270 $language = SL::DB::Language->new(id => $::form->{print_options}->{language_id})->load if $::form->{print_options}->{language_id};
272 # create a form for generate_attachment_filename
273 my $form = Form->new;
274 $form->{$self->nr_key()} = $self->order->number;
275 $form->{type} = $self->type;
276 $form->{format} = $format;
277 $form->{formname} = $formname;
278 $form->{language} = '_' . $language->template_code if $language;
279 my $pdf_filename = $form->generate_attachment_filename();
282 my @errors = generate_pdf($self->order, \$pdf, { format => $format,
283 formname => $formname,
284 language => $language,
285 groupitems => $groupitems });
286 if (scalar @errors) {
287 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
290 if ($media eq 'screen') {
292 my $sfile = SL::SessionFile::Random->new(mode => "w");
293 $sfile->fh->print($pdf);
296 my $key = join('_', Time::HiRes::gettimeofday(), int rand 1000000000000);
297 $::auth->set_session_value("Order::print-${key}" => $sfile->file_name);
300 ->run('kivi.Order.download_pdf', $pdf_filename, $key)
301 ->flash('info', t8('The PDF has been created'));
303 } elsif ($media eq 'printer') {
305 my $printer_id = $::form->{print_options}->{printer_id};
306 SL::DB::Printer->new(id => $printer_id)->load->print_document(
311 $self->js->flash('info', t8('The PDF has been printed'));
314 # copy file to webdav folder
315 if ($self->order->number && $::instance_conf->get_webdav_documents) {
316 my $webdav = SL::Webdav->new(
318 number => $self->order->number,
320 my $webdav_file = SL::Webdav::File->new(
322 filename => $pdf_filename,
325 $webdav_file->store(data => \$pdf);
328 $self->js->flash('error', t8('Storing PDF to webdav folder failed: #1', $@));
331 if ($self->order->number && $::instance_conf->get_doc_storage) {
333 SL::File->save(object_id => $self->order->id,
334 object_type => $self->type,
335 mime_type => 'application/pdf',
337 file_type => 'document',
338 file_name => $pdf_filename,
339 file_contents => $pdf);
342 $self->js->flash('error', t8('Storing PDF in storage backend failed: #1', $@));
348 # offer pdf for download
350 # It needs to get the key for the session value to get the pdf file.
351 sub action_download_pdf {
354 my $key = $::form->{key};
355 my $tmp_filename = $::auth->get_session_value("Order::print-${key}");
356 return $self->send_file(
358 type => 'application/pdf',
359 name => $::form->{pdf_filename},
363 # open the email dialog
364 sub action_show_email_dialog {
367 my $cv_method = $self->cv;
369 if (!$self->order->$cv_method) {
370 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'))
375 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
376 $email_form->{to} ||= $self->order->$cv_method->email;
377 $email_form->{cc} = $self->order->$cv_method->cc;
378 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
379 # Todo: get addresses from shipto, if any
381 my $form = Form->new;
382 $form->{$self->nr_key()} = $self->order->number;
383 $form->{formname} = $self->type;
384 $form->{type} = $self->type;
385 $form->{language} = 'de';
386 $form->{format} = 'pdf';
388 $email_form->{subject} = $form->generate_email_subject();
389 $email_form->{attachment_filename} = $form->generate_attachment_filename();
390 $email_form->{message} = $form->generate_email_body();
391 $email_form->{js_send_function} = 'kivi.Order.send_email()';
393 my %files = $self->get_files_for_email_dialog();
394 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
395 email_form => $email_form,
396 show_bcc => $::auth->assert('email_bcc', 'may fail'),
398 is_customer => $self->cv eq 'customer',
402 ->run('kivi.Order.show_email_dialog', $dialog_html)
409 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
410 sub action_send_email {
413 my $errors = $self->save();
415 if (scalar @{ $errors }) {
416 $self->js->run('kivi.Order.close_email_dialog');
417 $self->js->flash('error', $_) foreach @{ $errors };
418 return $self->js->render();
421 $self->js->val('#id', $self->order->id)
422 ->val('#order_' . $self->nr_key(), $self->order->number);
424 my $email_form = delete $::form->{email_form};
425 my %field_names = (to => 'email');
427 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
429 # for Form::cleanup which may be called in Form::send_email
430 $::form->{cwd} = getcwd();
431 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
433 $::form->{$_} = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
434 $::form->{media} = 'email';
436 if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) {
438 $language = SL::DB::Language->new(id => $::form->{print_options}->{language_id})->load if $::form->{print_options}->{language_id};
441 my @errors = generate_pdf($self->order, \$pdf, {media => $::form->{media},
442 format => $::form->{print_options}->{format},
443 formname => $::form->{print_options}->{formname},
444 language => $language,
445 groupitems => $::form->{print_options}->{groupitems}});
446 if (scalar @errors) {
447 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
450 my $sfile = SL::SessionFile::Random->new(mode => "w");
451 $sfile->fh->print($pdf);
454 $::form->{tmpfile} = $sfile->file_name;
455 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
458 $::form->send_email(\%::myconfig, 'pdf');
461 my $intnotes = $self->order->intnotes;
462 $intnotes .= "\n\n" if $self->order->intnotes;
463 $intnotes .= t8('[email]') . "\n";
464 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
465 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
466 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
467 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
468 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
469 $intnotes .= t8('Message') . ": " . $::form->{message};
471 $self->order->update_attributes(intnotes => $intnotes);
474 ->val('#order_intnotes', $intnotes)
475 ->run('kivi.Order.close_email_dialog')
476 ->flash('info', t8('The email has been sent.'))
480 # open the periodic invoices config dialog
482 # If there are values in the form (i.e. dialog was opened before),
483 # then use this values. Create new ones, else.
484 sub action_show_periodic_invoices_config_dialog {
487 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
488 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
489 $config ||= SL::DB::PeriodicInvoicesConfig->new(periodicity => 'm',
490 order_value_periodicity => 'p', # = same as periodicity
491 start_date_as_date => $::form->{transdate_as_date} || $::form->current_date,
492 extend_automatically_by => 12,
494 email_subject => GenericTranslations->get(
495 language_id => $::form->{language_id},
496 translation_type =>"preset_text_periodic_invoices_email_subject"),
497 email_body => GenericTranslations->get(
498 language_id => $::form->{language_id},
499 translation_type =>"preset_text_periodic_invoices_email_body"),
501 $config->periodicity('m') if none { $_ eq $config->periodicity } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
502 $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
504 $::form->get_lists(printers => "ALL_PRINTERS",
505 charts => { key => 'ALL_CHARTS',
506 transdate => 'current_date' });
508 $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
510 if ($::form->{customer_id}) {
511 $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
514 $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
516 popup_js_close_function => 'kivi.Order.close_periodic_invoices_config_dialog()',
517 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
522 # assign the values of the periodic invoices config dialog
523 # as yaml in the hidden tag and set the status.
524 sub action_assign_periodic_invoices_config {
527 $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
529 my $config = { active => $::form->{active} ? 1 : 0,
530 terminated => $::form->{terminated} ? 1 : 0,
531 direct_debit => $::form->{direct_debit} ? 1 : 0,
532 periodicity => (any { $_ eq $::form->{periodicity} } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES) ? $::form->{periodicity} : 'm',
533 order_value_periodicity => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
534 start_date_as_date => $::form->{start_date_as_date},
535 end_date_as_date => $::form->{end_date_as_date},
536 first_billing_date_as_date => $::form->{first_billing_date_as_date},
537 print => $::form->{print} ? 1 : 0,
538 printer_id => $::form->{print} ? $::form->{printer_id} * 1 : undef,
539 copies => $::form->{copies} * 1 ? $::form->{copies} : 1,
540 extend_automatically_by => $::form->{extend_automatically_by} * 1 || undef,
541 ar_chart_id => $::form->{ar_chart_id} * 1,
542 send_email => $::form->{send_email} ? 1 : 0,
543 email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
544 email_recipient_address => $::form->{email_recipient_address},
545 email_sender => $::form->{email_sender},
546 email_subject => $::form->{email_subject},
547 email_body => $::form->{email_body},
550 my $periodic_invoices_config = YAML::Dump($config);
552 my $status = $self->get_periodic_invoices_status($config);
555 ->remove('#order_periodic_invoices_config')
556 ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
557 ->run('kivi.Order.close_periodic_invoices_config_dialog')
558 ->html('#periodic_invoices_status', $status)
559 ->flash('info', t8('The periodic invoices config has been assigned.'))
563 sub action_get_has_active_periodic_invoices {
566 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
567 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
569 my $has_active_periodic_invoices =
570 $self->type eq sales_order_type()
573 && (!$config->end_date || ($config->end_date > DateTime->today_local))
574 && $config->get_previous_billed_period_start_date;
576 $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
579 # save the order and redirect to the frontend subroutine for a new
581 sub action_save_and_delivery_order {
584 my $errors = $self->save();
586 if (scalar @{ $errors }) {
587 $self->js->flash('error', $_) foreach @{ $errors };
588 return $self->js->render();
591 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
592 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
593 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
594 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
596 flash_later('info', $text);
598 my @redirect_params = (
599 controller => 'oe.pl',
600 action => 'oe_delivery_order_from_order',
601 id => $self->order->id,
604 $self->redirect_to(@redirect_params);
607 # save the order and redirect to the frontend subroutine for a new
609 sub action_save_and_invoice {
612 my $errors = $self->save();
614 if (scalar @{ $errors }) {
615 $self->js->flash('error', $_) foreach @{ $errors };
616 return $self->js->render();
619 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
620 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
621 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
622 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
624 flash_later('info', $text);
626 my @redirect_params = (
627 controller => 'oe.pl',
628 action => 'oe_invoice_from_order',
629 id => $self->order->id,
632 $self->redirect_to(@redirect_params);
635 # workflow from sales quotation to sales order
636 sub action_sales_order {
637 $_[0]->workflow_sales_or_purchase_order();
640 # workflow from rfq to purchase order
641 sub action_purchase_order {
642 $_[0]->workflow_sales_or_purchase_order();
645 # set form elements in respect to a changed customer or vendor
647 # This action is called on an change of the customer/vendor picker.
648 sub action_customer_vendor_changed {
651 setup_order_from_cv($self->order);
654 my $cv_method = $self->cv;
656 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
657 $self->js->show('#cp_row');
659 $self->js->hide('#cp_row');
662 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
663 $self->js->show('#shipto_row');
665 $self->js->hide('#shipto_row');
668 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
671 ->replaceWith('#order_cp_id', $self->build_contact_select)
672 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
673 ->replaceWith('#business_info_row', $self->build_business_info_row)
674 ->val( '#order_taxzone_id', $self->order->taxzone_id)
675 ->val( '#order_taxincluded', $self->order->taxincluded)
676 ->val( '#order_payment_id', $self->order->payment_id)
677 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
678 ->val( '#order_intnotes', $self->order->intnotes)
679 ->val( '#language_id', $self->order->$cv_method->language_id)
680 ->focus( '#order_' . $self->cv . '_id');
682 $self->js_redisplay_amounts_and_taxes;
686 # open the dialog for customer/vendor details
687 sub action_show_customer_vendor_details_dialog {
690 my $is_customer = 'customer' eq $::form->{vc};
693 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
695 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
698 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
699 $details{discount_as_percent} = $cv->discount_as_percent;
700 $details{creditlimt} = $cv->creditlimit_as_number;
701 $details{business} = $cv->business->description if $cv->business;
702 $details{language} = $cv->language_obj->description if $cv->language_obj;
703 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
704 $details{payment_terms} = $cv->payment->description if $cv->payment;
705 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
707 foreach my $entry (@{ $cv->shipto }) {
708 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
710 foreach my $entry (@{ $cv->contacts }) {
711 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
714 $_[0]->render('common/show_vc_details', { layout => 0 },
715 is_customer => $is_customer,
720 # called if a unit in an existing item row is changed
721 sub action_unit_changed {
724 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
725 my $item = $self->order->items_sorted->[$idx];
727 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
728 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
733 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
734 $self->js_redisplay_line_values;
735 $self->js_redisplay_amounts_and_taxes;
739 # add an item row for a new item entered in the input row
740 sub action_add_item {
743 my $form_attr = $::form->{add_item};
745 return unless $form_attr->{parts_id};
747 my $item = new_item($self->order, $form_attr);
749 $self->order->add_items($item);
753 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
754 my $row_as_html = $self->p->render('order/tabs/_row',
758 ALL_PRICE_FACTORS => $self->all_price_factors
762 ->append('#row_table_id', $row_as_html);
764 if ( $item->part->is_assortment ) {
765 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
766 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
767 my $attr = { parts_id => $assortment_item->parts_id,
768 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
769 unit => $assortment_item->unit,
770 description => $assortment_item->part->description,
772 my $item = new_item($self->order, $attr);
774 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
775 $item->discount(1) unless $assortment_item->charge;
777 $self->order->add_items( $item );
779 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
780 my $row_as_html = $self->p->render('order/tabs/_row',
784 ALL_PRICE_FACTORS => $self->all_price_factors
787 ->append('#row_table_id', $row_as_html);
792 ->val('.add_item_input', '')
793 ->run('kivi.Order.init_row_handlers')
794 ->run('kivi.Order.row_table_scroll_down')
795 ->run('kivi.Order.renumber_positions')
796 ->focus('#add_item_parts_id_name');
798 $self->js_redisplay_amounts_and_taxes;
802 # open the dialog for entering multiple items at once
803 sub action_show_multi_items_dialog {
804 $_[0]->render('order/tabs/_multi_items_dialog', { layout => 0 },
805 all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
808 # update the filter results in the multi item dialog
809 sub action_multi_items_update_result {
812 $::form->{multi_items}->{filter}->{obsolete} = 0;
814 my $count = $_[0]->multi_items_models->count;
817 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
818 $_[0]->render($text, { layout => 0 });
819 } elsif ($count > $max_count) {
820 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
821 $_[0]->render($text, { layout => 0 });
823 my $multi_items = $_[0]->multi_items_models->get;
824 $_[0]->render('order/tabs/_multi_items_result', { layout => 0 },
825 multi_items => $multi_items);
829 # add item rows for multiple items at once
830 sub action_add_multi_items {
833 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_multi_items} };
834 return $self->js->render() unless scalar @form_attr;
837 foreach my $attr (@form_attr) {
838 my $item = new_item($self->order, $attr);
840 if ( $item->part->is_assortment ) {
841 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
842 my $attr = { parts_id => $assortment_item->parts_id,
843 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
844 unit => $assortment_item->unit,
845 description => $assortment_item->part->description,
847 my $item = new_item($self->order, $attr);
849 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
850 $item->discount(1) unless $assortment_item->charge;
855 $self->order->add_items(@items);
859 foreach my $item (@items) {
860 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
861 my $row_as_html = $self->p->render('order/tabs/_row',
865 ALL_PRICE_FACTORS => $self->all_price_factors
868 $self->js->append('#row_table_id', $row_as_html);
872 ->run('kivi.Order.close_multi_items_dialog')
873 ->run('kivi.Order.init_row_handlers')
874 ->run('kivi.Order.row_table_scroll_down')
875 ->run('kivi.Order.renumber_positions')
876 ->focus('#add_item_parts_id_name');
878 $self->js_redisplay_amounts_and_taxes;
882 # recalculate all linetotals, amounts and taxes and redisplay them
883 sub action_recalc_amounts_and_taxes {
888 $self->js_redisplay_line_values;
889 $self->js_redisplay_amounts_and_taxes;
893 # redisplay item rows if they are sorted by an attribute
894 sub action_reorder_items {
898 partnumber => sub { $_[0]->part->partnumber },
899 description => sub { $_[0]->description },
900 qty => sub { $_[0]->qty },
901 sellprice => sub { $_[0]->sellprice },
902 discount => sub { $_[0]->discount },
905 my $method = $sort_keys{$::form->{order_by}};
906 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
907 if ($::form->{sort_dir}) {
908 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
910 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
913 ->run('kivi.Order.redisplay_items', \@to_sort)
917 # show the popup to choose a price/discount source
918 sub action_price_popup {
921 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
922 my $item = $self->order->items_sorted->[$idx];
924 $self->render_price_dialog($item);
927 # get the longdescription for an item if the dialog to enter/change the
928 # longdescription was opened and the longdescription is empty
930 # If this item is new, get the longdescription from Part.
931 # Otherwise get it from OrderItem.
932 sub action_get_item_longdescription {
935 if ($::form->{item_id}) {
936 $longdescription = SL::DB::OrderItem->new(id => $::form->{item_id})->load->longdescription;
937 } elsif ($::form->{parts_id}) {
938 $longdescription = SL::DB::Part->new(id => $::form->{parts_id})->load->notes;
940 $_[0]->render(\ $longdescription, { type => 'text' });
943 # load the second row for one or more items
945 # This action gets the html code for all items second rows by rendering a template for
946 # the second row and sets the html code via client js.
947 sub action_load_second_rows {
950 $self->recalc() if $self->order->is_sales; # for margin calculation
952 foreach my $item_id (@{ $::form->{item_ids} }) {
953 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
954 my $item = $self->order->items_sorted->[$idx];
956 $self->js_load_second_row($item, $item_id, 0);
959 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
964 sub js_load_second_row {
965 my ($self, $item, $item_id, $do_parse) = @_;
968 # Parse values from form (they are formated while rendering (template)).
969 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
970 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
971 foreach my $var (@{ $item->cvars_by_config }) {
972 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
974 $item->parse_custom_variable_values;
977 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
980 ->html('.row_entry:has(#item_' . $item_id . ') [name = "second_row"]', $row_as_html)
981 ->data('.row_entry:has(#item_' . $item_id . ') [name = "second_row"]', 'loaded', 1);
984 sub js_redisplay_line_values {
987 my $is_sales = $self->order->is_sales;
989 # sales orders with margins
994 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
995 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
996 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
997 ]} @{ $self->order->items_sorted };
1001 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1002 ]} @{ $self->order->items_sorted };
1006 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1009 sub js_redisplay_amounts_and_taxes {
1012 if (scalar @{ $self->{taxes} }) {
1013 $self->js->show('#taxincluded_row_id');
1015 $self->js->hide('#taxincluded_row_id');
1018 if ($self->order->taxincluded) {
1019 $self->js->hide('#subtotal_row_id');
1021 $self->js->show('#subtotal_row_id');
1024 if ($self->order->is_sales) {
1025 my $is_neg = $self->order->marge_total < 0;
1027 ->html('#marge_total_id', $::form->format_amount(\%::myconfig, $self->order->marge_total, 2))
1028 ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
1029 ->action_if( $is_neg, 'addClass', '#marge_total_id', 'plus0')
1030 ->action_if( $is_neg, 'addClass', '#marge_percent_id', 'plus0')
1031 ->action_if( $is_neg, 'addClass', '#marge_percent_sign_id', 'plus0')
1032 ->action_if(!$is_neg, 'removeClass', '#marge_total_id', 'plus0')
1033 ->action_if(!$is_neg, 'removeClass', '#marge_percent_id', 'plus0')
1034 ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
1038 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
1039 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
1040 ->remove('.tax_row')
1041 ->insertBefore($self->build_tax_rows, '#amount_row_id');
1048 sub init_valid_types {
1049 [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
1055 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1056 die "Not a valid type for order";
1059 $self->type($::form->{type});
1065 my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer'
1066 : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
1067 : die "Not a valid type for order";
1080 # model used to filter/display the parts in the multi-items dialog
1081 sub init_multi_items_models {
1082 SL::Controller::Helper::GetModels->new(
1083 controller => $_[0],
1085 with_objects => [ qw(unit_obj) ],
1086 disable_plugin => 'paginated',
1087 source => $::form->{multi_items},
1093 partnumber => t8('Partnumber'),
1094 description => t8('Description')}
1098 sub init_all_price_factors {
1099 SL::DB::Manager::PriceFactor->get_all;
1105 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
1107 my $right = $right_for->{ $self->type };
1108 $right ||= 'DOES_NOT_EXIST';
1110 $::auth->assert($right);
1113 # build the selection box for contacts
1115 # Needed, if customer/vendor changed.
1116 sub build_contact_select {
1119 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1120 value_key => 'cp_id',
1121 title_key => 'full_name_dep',
1122 default => $self->order->cp_id,
1124 style => 'width: 300px',
1128 # build the selection box for shiptos
1130 # Needed, if customer/vendor changed.
1131 sub build_shipto_select {
1134 select_tag('order.shipto_id', [ $self->order->{$self->cv}->shipto ],
1135 value_key => 'shipto_id',
1136 title_key => 'displayable_id',
1137 default => $self->order->shipto_id,
1139 style => 'width: 300px',
1143 # render the info line for business
1145 # Needed, if customer/vendor changed.
1146 sub build_business_info_row
1148 $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
1151 # build the rows for displaying taxes
1153 # Called if amounts where recalculated and redisplayed.
1154 sub build_tax_rows {
1158 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
1159 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
1161 return $rows_as_html;
1165 sub render_price_dialog {
1166 my ($self, $record_item) = @_;
1168 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1172 'kivi.io.price_chooser_dialog',
1173 t8('Available Prices'),
1174 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1179 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1180 # $self->js->show('#dialog_flash_error');
1189 return if !$::form->{id};
1191 $self->order(SL::DB::Order->new(id => $::form->{id})->load);
1194 # load or create a new order object
1196 # And assign changes from the form to this object.
1197 # If the order is loaded from db, check if items are deleted in the form,
1198 # remove them form the object and collect them for removing from db on saving.
1199 # Then create/update items from form (via make_item) and add them.
1203 # add_items adds items to an order with no items for saving, but they cannot
1204 # be retrieved via items until the order is saved. Adding empty items to new
1205 # order here solves this problem.
1207 $order = SL::DB::Manager::Order->find_by(id => $::form->{id}) if $::form->{id};
1208 $order ||= SL::DB::Order->new(orderitems => [],
1209 quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())));
1211 my $cv_id_method = $self->cv . '_id';
1212 if (!$::form->{id} && $::form->{$cv_id_method}) {
1213 $order->$cv_id_method($::form->{$cv_id_method});
1214 setup_order_from_cv($order);
1217 my $form_orderitems = delete $::form->{order}->{orderitems};
1218 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1220 $order->assign_attributes(%{$::form->{order}});
1222 if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? YAML::Load($form_periodic_invoices_config) : undef) {
1223 my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
1224 $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
1227 # remove deleted items
1228 $self->item_ids_to_delete([]);
1229 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1230 my $item = $order->orderitems->[$idx];
1231 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1232 splice @{$order->orderitems}, $idx, 1;
1233 push @{$self->item_ids_to_delete}, $item->id;
1239 foreach my $form_attr (@{$form_orderitems}) {
1240 my $item = make_item($order, $form_attr);
1241 $item->position($pos);
1245 $order->add_items(grep {!$_->id} @items);
1250 # create or update items from form
1252 # Make item objects from form values. For items already existing read from db.
1253 # Create a new item else. And assign attributes.
1255 my ($record, $attr) = @_;
1258 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1260 my $is_new = !$item;
1262 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1263 # they cannot be retrieved via custom_variables until the order/orderitem is
1264 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1265 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1267 $item->assign_attributes(%$attr);
1268 $item->longdescription($item->part->notes) if $is_new && !defined $attr->{longdescription};
1269 $item->project_id($record->globalproject_id) if $is_new && !defined $attr->{project_id};
1270 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if $is_new && !defined $attr->{lastcost_as_number};
1277 # This is used to add one item
1279 my ($record, $attr) = @_;
1281 my $item = SL::DB::OrderItem->new;
1283 # Remove attributes where the user left or set the inputs empty.
1284 # So these attributes will be undefined and we can distinguish them
1285 # from zero later on.
1286 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1287 delete $attr->{$_} if $attr->{$_} eq '';
1290 $item->assign_attributes(%$attr);
1292 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1293 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1295 $item->unit($part->unit) if !$item->unit;
1298 if ( $part->is_assortment ) {
1299 # add assortment items with price 0, as the components carry the price
1300 $price_src = $price_source->price_from_source("");
1301 $price_src->price(0);
1302 } elsif (defined $item->sellprice) {
1303 $price_src = $price_source->price_from_source("");
1304 $price_src->price($item->sellprice);
1306 $price_src = $price_source->best_price
1307 ? $price_source->best_price
1308 : $price_source->price_from_source("");
1309 $price_src->price(0) if !$price_source->best_price;
1313 if (defined $item->discount) {
1314 $discount_src = $price_source->discount_from_source("");
1315 $discount_src->discount($item->discount);
1317 $discount_src = $price_source->best_discount
1318 ? $price_source->best_discount
1319 : $price_source->discount_from_source("");
1320 $discount_src->discount(0) if !$price_source->best_discount;
1324 $new_attr{part} = $part;
1325 $new_attr{description} = $part->description if ! $item->description;
1326 $new_attr{qty} = 1.0 if ! $item->qty;
1327 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1328 $new_attr{sellprice} = $price_src->price;
1329 $new_attr{discount} = $discount_src->discount;
1330 $new_attr{active_price_source} = $price_src;
1331 $new_attr{active_discount_source} = $discount_src;
1332 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1333 $new_attr{project_id} = $record->globalproject_id;
1334 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1336 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1337 # they cannot be retrieved via custom_variables until the order/orderitem is
1338 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1339 $new_attr{custom_variables} = [];
1341 $item->assign_attributes(%new_attr);
1346 sub setup_order_from_cv {
1349 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id));
1351 $order->intnotes($order->customervendor->notes);
1353 if ($order->is_sales) {
1354 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1355 $order->taxincluded(defined($order->customer->taxincluded_checked)
1356 ? $order->customer->taxincluded_checked
1357 : $::myconfig{taxincluded_checked});
1362 # recalculate prices and taxes
1364 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1368 # bb: todo: currency later
1369 $self->order->currency_id($::instance_conf->get_currency_id());
1371 my %pat = $self->order->calculate_prices_and_taxes();
1372 $self->{taxes} = [];
1373 foreach my $tax_chart_id (keys %{ $pat{taxes} }) {
1374 my $tax = SL::DB::Manager::Tax->find_by(chart_id => $tax_chart_id);
1376 my @amount_keys = grep { $pat{amounts}->{$_}->{tax_id} == $tax->id } keys %{ $pat{amounts} };
1377 push(@{ $self->{taxes} }, { amount => $pat{taxes}->{$tax_chart_id},
1378 netamount => $pat{amounts}->{$amount_keys[0]}->{amount},
1382 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items}, @{$pat{items}};
1385 # get data for saving, printing, ..., that is not changed in the form
1387 # Only cvars for now.
1388 sub get_unalterable_data {
1391 foreach my $item (@{ $self->order->items }) {
1392 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1393 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1394 foreach my $var (@{ $item->cvars_by_config }) {
1395 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1397 $item->parse_custom_variable_values;
1403 # And remove related files in the spool directory
1408 my $db = $self->order->db;
1410 $db->with_transaction(
1412 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1413 $self->order->delete;
1414 my $spool = $::lx_office_conf{paths}->{spool};
1415 unlink map { "$spool/$_" } @spoolfiles if $spool;
1418 }) || push(@{$errors}, $db->error);
1425 # And delete items that are deleted in the form.
1430 my $db = $self->order->db;
1432 $db->with_transaction(sub {
1433 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1434 $self->order->save(cascade => 1);
1437 if ($::form->{converted_from_oe_id}) {
1438 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1439 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1440 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1441 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
1442 $src->link_to_record($self->order);
1444 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1446 foreach (@{ $self->order->items_sorted }) {
1447 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1449 SL::DB::RecordLink->new(from_table => 'orderitems',
1450 from_id => $from_id,
1451 to_table => 'orderitems',
1459 }) || push(@{$errors}, $db->error);
1464 sub workflow_sales_or_purchase_order {
1467 my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type()
1468 : $::form->{type} eq request_quotation_type() ? purchase_order_type()
1469 : $::form->{type} eq purchase_order_type() ? sales_order_type()
1470 : $::form->{type} eq sales_order_type() ? purchase_order_type()
1473 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1474 $self->{converted_from_oe_id} = delete $::form->{id};
1476 # set item ids to new fake id, to identify them as new items
1477 foreach my $item (@{$self->order->items_sorted}) {
1478 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1482 $::form->{type} = $destination_type;
1483 $self->type($self->init_type);
1484 $self->cv ($self->init_cv);
1488 $self->get_unalterable_data();
1489 $self->pre_render();
1491 # trigger rendering values for second row/longdescription as hidden,
1492 # because they are loaded only on demand. So we need to keep the values
1494 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1495 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
1499 title => $self->get_title_for('edit'),
1500 %{$self->{template_args}}
1508 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1509 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1510 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1513 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1516 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1518 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1519 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1520 $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
1521 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1523 my $print_form = Form->new('');
1524 $print_form->{type} = $self->type;
1525 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1526 $print_form->{languages} = SL::DB::Manager::Language->get_all_sorted;
1527 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1528 form => $print_form,
1529 options => {dialog_name_prefix => 'print_options.',
1533 no_opendocument => 0,
1537 foreach my $item (@{$self->order->orderitems}) {
1538 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1539 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1540 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1543 if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
1544 # calculate shipped qtys here to prevent calling calculate for every item via the items method
1545 SL::Helper::ShippedQty->new->calculate($self->order)->write_to_objects;
1548 if ($self->order->number && $::instance_conf->get_webdav) {
1549 my $webdav = SL::Webdav->new(
1550 type => $self->type,
1551 number => $self->order->number,
1553 my @all_objects = $webdav->get_all_objects;
1554 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1556 link => File::Spec->catfile($_->full_filedescriptor),
1560 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery edit_periodic_invoices_config calculate_qty);
1561 $self->setup_edit_action_bar;
1564 sub setup_edit_action_bar {
1565 my ($self, %params) = @_;
1567 my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
1568 || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
1569 || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
1571 for my $bar ($::request->layout->get('actionbar')) {
1576 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1577 $::instance_conf->get_order_warn_no_deliverydate,
1579 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1583 call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1584 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1585 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1587 ], # end of combobox "Save"
1595 submit => [ '#order_form', { action => "Order/sales_order" } ],
1596 only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
1597 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1600 t8('Purchase Order'),
1601 submit => [ '#order_form', { action => "Order/purchase_order" } ],
1602 only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
1603 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1606 t8('Save and Delivery Order'),
1607 call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1608 $::instance_conf->get_order_warn_no_deliverydate,
1610 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1611 only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type()))
1614 t8('Save and Invoice'),
1615 call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1616 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1618 ], # end of combobox "Workflow"
1625 t8('Save and print'),
1626 call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts ],
1629 t8('Save and E-mail'),
1630 call => [ 'kivi.Order.email', $::instance_conf->get_order_warn_duplicate_parts ],
1633 t8('Download attachments of all parts'),
1634 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1635 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1636 only_if => $::instance_conf->get_doc_storage,
1638 ], # end of combobox "Export"
1642 call => [ 'kivi.Order.delete_order' ],
1643 confirm => $::locale->text('Do you really want to delete this object?'),
1644 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1645 only_if => $deletion_allowed,
1652 my ($order, $pdf_ref, $params) = @_;
1656 my $print_form = Form->new('');
1657 $print_form->{type} = $order->type;
1658 $print_form->{formname} = $params->{formname} || $order->type;
1659 $print_form->{format} = $params->{format} || 'pdf';
1660 $print_form->{media} = $params->{media} || 'file';
1661 $print_form->{groupitems} = $params->{groupitems};
1662 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1664 $order->language($params->{language});
1665 $order->flatten_to_form($print_form, format_amounts => 1);
1669 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
1670 $template_ext = 'odt';
1671 $template_type = 'OpenDocument';
1674 # search for the template
1675 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1676 name => $print_form->{formname},
1677 extension => $template_ext,
1678 email => $print_form->{media} eq 'email',
1679 language => $params->{language},
1680 printer_id => $print_form->{printer_id}, # todo
1683 if (!defined $template_file) {
1684 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);
1687 return @errors if scalar @errors;
1689 $print_form->throw_on_error(sub {
1691 $print_form->prepare_for_printing;
1693 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1694 format => $print_form->{format},
1695 template_type => $template_type,
1696 template => $template_file,
1697 variables => $print_form,
1698 variable_content_types => {
1699 longdescription => 'html',
1700 partnotes => 'html',
1705 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->getMessage : $EVAL_ERROR;
1711 sub get_files_for_email_dialog {
1714 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
1716 return %files if !$::instance_conf->get_doc_storage;
1718 if ($self->order->id) {
1719 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
1720 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
1721 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
1725 uniq_by { $_->{id} }
1727 +{ id => $_->part->id,
1728 partnumber => $_->part->partnumber }
1729 } @{$self->order->items_sorted};
1731 foreach my $part (@parts) {
1732 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
1733 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
1736 foreach my $key (keys %files) {
1737 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
1743 sub make_periodic_invoices_config_from_yaml {
1744 my ($yaml_config) = @_;
1746 return if !$yaml_config;
1747 my $attr = YAML::Load($yaml_config);
1748 return if 'HASH' ne ref $attr;
1749 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
1753 sub get_periodic_invoices_status {
1754 my ($self, $config) = @_;
1756 return if $self->type ne sales_order_type();
1757 return t8('not configured') if !$config;
1759 my $active = ('HASH' eq ref $config) ? $config->{active}
1760 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
1761 : die "Cannot get status of periodic invoices config";
1763 return $active ? t8('active') : t8('inactive');
1767 my ($self, $action) = @_;
1769 return '' if none { lc($action)} qw(add edit);
1772 # $::locale->text("Add Sales Order");
1773 # $::locale->text("Add Purchase Order");
1774 # $::locale->text("Add Quotation");
1775 # $::locale->text("Add Request for Quotation");
1776 # $::locale->text("Edit Sales Order");
1777 # $::locale->text("Edit Purchase Order");
1778 # $::locale->text("Edit Quotation");
1779 # $::locale->text("Edit Request for Quotation");
1781 $action = ucfirst(lc($action));
1782 return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order")
1783 : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order")
1784 : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation")
1785 : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
1789 sub sales_order_type {
1793 sub purchase_order_type {
1797 sub sales_quotation_type {
1801 sub request_quotation_type {
1802 'request_quotation';
1806 return $_[0]->type eq sales_order_type() ? 'ordnumber'
1807 : $_[0]->type eq purchase_order_type() ? 'ordnumber'
1808 : $_[0]->type eq sales_quotation_type() ? 'quonumber'
1809 : $_[0]->type eq request_quotation_type() ? 'quonumber'
1821 SL::Controller::Order - controller for orders
1825 This is a new form to enter orders, completely rewritten with the use
1826 of controller and java script techniques.
1828 The aim is to provide the user a better expirience and a faster flow
1829 of work. Also the code should be more readable, more reliable and
1838 One input row, so that input happens every time at the same place.
1842 Use of pickers where possible.
1846 Possibility to enter more than one item at once.
1850 Item list in a scrollable area, so that the workflow buttons stay at
1855 Reordering item rows with drag and drop is possible. Sorting item rows is
1856 possible (by partnumber, description, qty, sellprice and discount for now).
1860 No C<update> is necessary. All entries and calculations are managed
1861 with ajax-calls and the page does only reload on C<save>.
1865 User can see changes immediately, because of the use of java script
1876 =item * C<SL/Controller/Order.pm>
1880 =item * C<template/webpages/order/form.html>
1884 =item * C<template/webpages/order/tabs/basic_data.html>
1886 Main tab for basic_data.
1888 This is the only tab here for now. "linked records" and "webdav" tabs are
1889 reused from generic code.
1893 =item * C<template/webpages/order/tabs/_business_info_row.html>
1895 For displaying information on business type
1897 =item * C<template/webpages/order/tabs/_item_input.html>
1899 The input line for items
1901 =item * C<template/webpages/order/tabs/_row.html>
1903 One row for already entered items
1905 =item * C<template/webpages/order/tabs/_tax_row.html>
1907 Displaying tax information
1909 =item * C<template/webpages/order/tabs/_multi_items_dialog.html>
1911 Dialog for entering more than one item at once
1913 =item * C<template/webpages/order/tabs/_multi_items_result.html>
1915 Results for the filter in the multi items dialog
1917 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
1919 Dialog for selecting price and discount sources
1923 =item * C<js/kivi.Order.js>
1925 java script functions
1937 =item * credit limit
1939 =item * more workflows (quotation, rfq)
1941 =item * price sources: little symbols showing better price / better discount
1943 =item * select units in input row?
1945 =item * custom shipto address
1947 =item * check for direct delivery (workflow sales order -> purchase order)
1949 =item * language / part translations
1951 =item * access rights
1953 =item * display weights
1959 =item * optional client/user behaviour
1961 (transactions has to be set - department has to be set -
1962 force project if enabled in client config - transport cost reminder)
1966 =head1 KNOWN BUGS AND CAVEATS
1972 Customer discount is not displayed as a valid discount in price source popup
1973 (this might be a bug in price sources)
1975 (I cannot reproduce this (Bernd))
1979 No indication that <shift>-up/down expands/collapses second row.
1983 Inline creation of parts is not currently supported
1987 Table header is not sticky in the scrolling area.
1991 Sorting does not include C<position>, neither does reordering.
1993 This behavior was implemented intentionally. But we can discuss, which behavior
1994 should be implemented.
1998 C<show_multi_items_dialog> does not use the currently inserted string for
2003 The language selected in print or email dialog is not saved when the order is saved.
2007 =head1 To discuss / Nice to have
2013 How to expand/collapse second row. Now it can be done clicking the icon or
2018 Possibility to change longdescription in input row?
2022 Possibility to select PriceSources in input row?
2026 This controller uses a (changed) copy of the template for the PriceSource
2027 dialog. Maybe there could be used one code source.
2031 Rounding-differences between this controller (PriceTaxCalculator) and the old
2032 form. This is not only a problem here, but also in all parts using the PTC.
2033 There exists a ticket and a patch. This patch should be testet.
2037 An indicator, if the actual inputs are saved (like in an
2038 editor or on text processing application).
2042 A warning when leaving the page without saveing unchanged inputs.
2049 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>