1 package SL::Controller::Order;
4 use parent qw(SL::Controller::Base);
6 use SL::Helper::Flash qw(flash_later);
8 use SL::Presenter::Tag qw(select_tag hidden_tag div_tag);
9 use SL::Locale::String qw(t8);
10 use SL::SessionFile::Random;
15 use SL::Util qw(trim);
17 use SL::DB::AdditionalBillingAddress;
24 use SL::DB::PartClassification;
25 use SL::DB::PartsGroup;
28 use SL::DB::RecordLink;
29 use SL::DB::RequirementSpec;
31 use SL::DB::Translation;
33 use SL::Helper::CreatePDF qw(:all);
34 use SL::Helper::PrintOptions;
35 use SL::Helper::ShippedQty;
36 use SL::Helper::UserPreferences::PositionsScrollbar;
37 use SL::Helper::UserPreferences::UpdatePositions;
39 use SL::Controller::Helper::GetModels;
41 use List::Util qw(first sum0);
42 use List::UtilsBy qw(sort_by uniq_by);
43 use List::MoreUtils qw(any none pairwise first_index);
44 use English qw(-no_match_vars);
49 use Rose::Object::MakeMethods::Generic
51 scalar => [ qw(item_ids_to_delete is_custom_shipto_to_delete) ],
52 'scalar --get_set_init' => [ qw(order valid_types type cv p all_price_factors search_cvpartnumber show_update_button part_picker_classification_ids) ],
57 __PACKAGE__->run_before('check_auth');
59 __PACKAGE__->run_before('check_auth_for_edit',
60 except => [ qw(edit show_customer_vendor_details_dialog price_popup load_second_rows) ]);
62 __PACKAGE__->run_before('recalc',
63 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_invoice_for_advance_payment save_and_final_invoice save_and_ap_transaction
66 __PACKAGE__->run_before('get_unalterable_data',
67 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_invoice_for_advance_payment save_and_final_invoice save_and_ap_transaction
78 $self->order->transdate(DateTime->now_local());
79 my $extra_days = $self->type eq sales_quotation_type() ? $::instance_conf->get_reqdate_interval :
80 $self->type eq sales_order_type() ? $::instance_conf->get_delivery_date_interval : 1;
82 if ( ($self->type eq sales_order_type() && $::instance_conf->get_deliverydate_on)
83 || ($self->type eq sales_quotation_type() && $::instance_conf->get_reqdate_on)
84 && (!$self->order->reqdate)) {
85 $self->order->reqdate(DateTime->today_local->next_workday(extra_days => $extra_days));
92 title => $self->get_title_for('add'),
93 %{$self->{template_args}}
97 # edit an existing order
105 # this is to edit an order from an unsaved order object
107 # set item ids to new fake id, to identify them as new items
108 foreach my $item (@{$self->order->items_sorted}) {
109 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
111 # trigger rendering values for second row as hidden, because they
112 # are loaded only on demand. So we need to keep the values from
114 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
121 title => $self->get_title_for('edit'),
122 %{$self->{template_args}}
126 # edit a collective order (consisting of one or more existing orders)
127 sub action_edit_collective {
131 my @multi_ids = map {
132 $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1}
133 } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form;
135 # fall back to add if no ids are given
136 if (scalar @multi_ids == 0) {
141 # fall back to save as new if only one id is given
142 if (scalar @multi_ids == 1) {
143 $self->order(SL::DB::Order->new(id => $multi_ids[0])->load);
144 $self->action_save_as_new();
148 # make new order from given orders
149 my @multi_orders = map { SL::DB::Order->new(id => $_)->load } @multi_ids;
150 $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders;
151 $self->order(SL::DB::Order->new_from_multi(\@multi_orders, sort_sources_by => 'transdate'));
153 $self->action_edit();
160 my $errors = $self->delete();
162 if (scalar @{ $errors }) {
163 $self->js->flash('error', $_) foreach @{ $errors };
164 return $self->js->render();
167 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been deleted')
168 : $self->type eq purchase_order_type() ? $::locale->text('The order has been deleted')
169 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been deleted')
170 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been deleted')
172 flash_later('info', $text);
174 my @redirect_params = (
179 $self->redirect_to(@redirect_params);
186 my $errors = $self->save();
188 if (scalar @{ $errors }) {
189 $self->js->flash('error', $_) foreach @{ $errors };
190 return $self->js->render();
193 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
194 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
195 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
196 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
198 flash_later('info', $text);
200 my @redirect_params = (
203 id => $self->order->id,
206 $self->redirect_to(@redirect_params);
209 # save the order as new document an open it for edit
210 sub action_save_as_new {
213 my $order = $self->order;
216 $self->js->flash('error', t8('This object has not been saved yet.'));
217 return $self->js->render();
220 # load order from db to check if values changed
221 my $saved_order = SL::DB::Order->new(id => $order->id)->load;
224 # Lets assign a new number if the user hasn't changed the previous one.
225 # If it has been changed manually then use it as-is.
226 $new_attrs{number} = (trim($order->number) eq $saved_order->number)
228 : trim($order->number);
230 # Clear transdate unless changed
231 $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
232 ? DateTime->today_local
235 # Set new reqdate unless changed if it is enabled in client config
236 if ($order->reqdate == $saved_order->reqdate) {
237 my $extra_days = $self->type eq sales_quotation_type() ? $::instance_conf->get_reqdate_interval :
238 $self->type eq sales_order_type() ? $::instance_conf->get_delivery_date_interval : 1;
240 if ( ($self->type eq sales_order_type() && !$::instance_conf->get_deliverydate_on)
241 || ($self->type eq sales_quotation_type() && !$::instance_conf->get_reqdate_on)) {
242 $new_attrs{reqdate} = '';
244 $new_attrs{reqdate} = DateTime->today_local->next_workday(extra_days => $extra_days);
247 $new_attrs{reqdate} = $order->reqdate;
251 $new_attrs{employee} = SL::DB::Manager::Employee->current;
253 # Create new record from current one
254 $self->order(SL::DB::Order->new_from($order, destination_type => $order->type, attributes => \%new_attrs));
256 # no linked records on save as new
257 delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
260 $self->action_save();
265 # This is called if "print" is pressed in the print dialog.
266 # If PDF creation was requested and succeeded, the pdf is offered for download
267 # via send_file (which uses ajax in this case).
271 my $errors = $self->save();
273 if (scalar @{ $errors }) {
274 $self->js->flash('error', $_) foreach @{ $errors };
275 return $self->js->render();
278 $self->js_reset_order_and_item_ids_after_save;
280 my $format = $::form->{print_options}->{format};
281 my $media = $::form->{print_options}->{media};
282 my $formname = $::form->{print_options}->{formname};
283 my $copies = $::form->{print_options}->{copies};
284 my $groupitems = $::form->{print_options}->{groupitems};
285 my $printer_id = $::form->{print_options}->{printer_id};
287 # only PDF, OpenDocument & HTML for now
288 if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf html)) {
289 return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
292 # only screen or printer by now
293 if (none { $media eq $_ } qw(screen printer)) {
294 return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
297 # create a form for generate_attachment_filename
298 my $form = Form->new;
299 $form->{$self->nr_key()} = $self->order->number;
300 $form->{type} = $self->type;
301 $form->{format} = $format;
302 $form->{formname} = $formname;
303 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
304 my $doc_filename = $form->generate_attachment_filename();
307 my @errors = $self->generate_doc(\$doc, { media => $media,
309 formname => $formname,
310 language => $self->order->language,
311 printer_id => $printer_id,
312 groupitems => $groupitems });
313 if (scalar @errors) {
314 return $self->js->flash('error', t8('Generating the document failed: #1', $errors[0]))->render;
317 if ($media eq 'screen') {
319 $self->js->flash('info', t8('The document has been created.'));
322 type => SL::MIME->mime_type_from_ext($doc_filename),
323 name => $doc_filename,
327 } elsif ($media eq 'printer') {
329 my $printer_id = $::form->{print_options}->{printer_id};
330 SL::DB::Printer->new(id => $printer_id)->load->print_document(
335 $self->js->flash('info', t8('The document has been printed.'));
338 my @warnings = $self->store_doc_to_webdav_and_filemanagement($doc, $doc_filename, $formname);
339 if (scalar @warnings) {
340 $self->js->flash('warning', $_) for @warnings;
343 $self->save_history('PRINTED');
346 ->run('kivi.ActionBar.setEnabled', '#save_and_email_action')
349 sub action_preview_pdf {
352 my $errors = $self->save();
353 if (scalar @{ $errors }) {
354 $self->js->flash('error', $_) foreach @{ $errors };
355 return $self->js->render();
358 $self->js_reset_order_and_item_ids_after_save;
361 my $media = 'screen';
362 my $formname = $self->type;
365 # create a form for generate_attachment_filename
366 my $form = Form->new;
367 $form->{$self->nr_key()} = $self->order->number;
368 $form->{type} = $self->type;
369 $form->{format} = $format;
370 $form->{formname} = $formname;
371 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
372 my $pdf_filename = $form->generate_attachment_filename();
375 my @errors = $self->generate_doc(\$pdf, { media => $media,
377 formname => $formname,
378 language => $self->order->language,
380 if (scalar @errors) {
381 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
383 $self->save_history('PREVIEWED');
384 $self->js->flash('info', t8('The PDF has been previewed'));
388 type => SL::MIME->mime_type_from_ext($pdf_filename),
389 name => $pdf_filename,
394 # open the email dialog
395 sub action_save_and_show_email_dialog {
398 my $errors = $self->save();
400 if (scalar @{ $errors }) {
401 $self->js->flash('error', $_) foreach @{ $errors };
402 return $self->js->render();
405 my $cv_method = $self->cv;
407 if (!$self->order->$cv_method) {
408 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'))
413 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
414 $email_form->{to} ||= $self->order->$cv_method->email;
415 $email_form->{cc} = $self->order->$cv_method->cc;
416 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
417 # Todo: get addresses from shipto, if any
419 my $form = Form->new;
420 $form->{$self->nr_key()} = $self->order->number;
421 $form->{cusordnumber} = $self->order->cusordnumber;
422 $form->{formname} = $self->type;
423 $form->{type} = $self->type;
424 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
425 $form->{language_id} = $self->order->language->id if $self->order->language;
426 $form->{format} = 'pdf';
427 $form->{cp_id} = $self->order->contact->cp_id if $self->order->contact;
429 $email_form->{subject} = $form->generate_email_subject();
430 $email_form->{attachment_filename} = $form->generate_attachment_filename();
431 $email_form->{message} = $form->generate_email_body();
432 $email_form->{js_send_function} = 'kivi.Order.send_email()';
434 my %files = $self->get_files_for_email_dialog();
436 my @employees_with_email = grep {
437 my $user = SL::DB::Manager::AuthUser->find_by(login => $_->login);
438 $user && !!trim($user->get_config_value('email'));
439 } @{ SL::DB::Manager::Employee->get_all_sorted(query => [ deleted => 0 ]) };
441 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
442 email_form => $email_form,
443 show_bcc => $::auth->assert('email_bcc', 'may fail'),
445 is_customer => $self->cv eq 'customer',
446 ALL_EMPLOYEES => \@employees_with_email,
450 ->run('kivi.Order.show_email_dialog', $dialog_html)
457 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
458 sub action_send_email {
461 my $errors = $self->save();
463 if (scalar @{ $errors }) {
464 $self->js->run('kivi.Order.close_email_dialog');
465 $self->js->flash('error', $_) foreach @{ $errors };
466 return $self->js->render();
469 $self->js_reset_order_and_item_ids_after_save;
471 my $email_form = delete $::form->{email_form};
472 my %field_names = (to => 'email');
474 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
476 # for Form::cleanup which may be called in Form::send_email
477 $::form->{cwd} = getcwd();
478 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
480 $::form->{$_} = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
481 $::form->{media} = 'email';
483 $::form->{attachment_policy} //= '';
485 # Is an old file version available?
487 if ($::form->{attachment_policy} eq 'old_file') {
488 $attfile = SL::File->get_all(object_id => $self->order->id,
489 object_type => $self->type,
490 file_type => 'document',
491 print_variant => $::form->{formname});
494 if ($::form->{attachment_policy} ne 'no_file' && !($::form->{attachment_policy} eq 'old_file' && $attfile)) {
496 my @errors = $self->generate_doc(\$doc, {media => $::form->{media},
497 format => $::form->{print_options}->{format},
498 formname => $::form->{print_options}->{formname},
499 language => $self->order->language,
500 printer_id => $::form->{print_options}->{printer_id},
501 groupitems => $::form->{print_options}->{groupitems}});
502 if (scalar @errors) {
503 return $self->js->flash('error', t8('Generating the document failed: #1', $errors[0]))->render($self);
506 my @warnings = $self->store_doc_to_webdav_and_filemanagement($doc, $::form->{attachment_filename}, $::form->{formname});
507 if (scalar @warnings) {
508 flash_later('warning', $_) for @warnings;
511 my $sfile = SL::SessionFile::Random->new(mode => "w");
512 $sfile->fh->print($doc);
515 $::form->{tmpfile} = $sfile->file_name;
516 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
519 $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a linked record to the mail
520 $::form->send_email(\%::myconfig, $::form->{print_options}->{format});
522 # internal notes unless no email journal
523 unless ($::instance_conf->get_email_journal) {
524 my $intnotes = $self->order->intnotes;
525 $intnotes .= "\n\n" if $self->order->intnotes;
526 $intnotes .= t8('[email]') . "\n";
527 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
528 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
529 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
530 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
531 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
532 $intnotes .= t8('Message') . ": " . SL::HTML::Util->strip($::form->{message});
534 $self->order->update_attributes(intnotes => $intnotes);
537 $self->save_history('MAILED');
539 flash_later('info', t8('The email has been sent.'));
541 my @redirect_params = (
544 id => $self->order->id,
547 $self->redirect_to(@redirect_params);
550 # open the periodic invoices config dialog
552 # If there are values in the form (i.e. dialog was opened before),
553 # then use this values. Create new ones, else.
554 sub action_show_periodic_invoices_config_dialog {
557 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
558 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
559 $config ||= SL::DB::PeriodicInvoicesConfig->new(periodicity => 'm',
560 order_value_periodicity => 'p', # = same as periodicity
561 start_date_as_date => $::form->{transdate_as_date} || $::form->current_date,
562 extend_automatically_by => 12,
564 email_subject => GenericTranslations->get(
565 language_id => $::form->{language_id},
566 translation_type =>"preset_text_periodic_invoices_email_subject"),
567 email_body => GenericTranslations->get(
568 language_id => $::form->{language_id},
569 translation_type => "salutation_general")
570 . GenericTranslations->get(
571 language_id => $::form->{language_id},
572 translation_type => "salutation_punctuation_mark") . "\n\n"
573 . GenericTranslations->get(
574 language_id => $::form->{language_id},
575 translation_type =>"preset_text_periodic_invoices_email_body"),
577 # for older configs, replace email preset text if not yet set.
578 $config->email_subject(GenericTranslations->get(
579 language_id => $::form->{language_id},
580 translation_type =>"preset_text_periodic_invoices_email_subject")
581 ) unless $config->email_subject;
583 $config->email_body(GenericTranslations->get(
584 language_id => $::form->{language_id},
585 translation_type => "salutation_general")
586 . GenericTranslations->get(
587 language_id => $::form->{language_id},
588 translation_type => "salutation_punctuation_mark") . "\n\n"
589 . GenericTranslations->get(
590 language_id => $::form->{language_id},
591 translation_type =>"preset_text_periodic_invoices_email_body")
592 ) unless $config->email_body;
594 $config->periodicity('m') if none { $_ eq $config->periodicity } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
595 $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
597 $::form->get_lists(printers => "ALL_PRINTERS",
598 charts => { key => 'ALL_CHARTS',
599 transdate => 'current_date' });
601 $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
603 if ($::form->{customer_id}) {
604 $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
605 my $customer_object = SL::DB::Manager::Customer->find_by(id => $::form->{customer_id});
606 $::form->{postal_invoice} = $customer_object->postal_invoice;
607 $::form->{email_recipient_invoice_address} = $::form->{postal_invoice} ? '' : $customer_object->invoice_mail;
608 $config->send_email(0) if $::form->{postal_invoice};
611 $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
613 popup_js_close_function => 'kivi.Order.close_periodic_invoices_config_dialog()',
614 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
619 # assign the values of the periodic invoices config dialog
620 # as yaml in the hidden tag and set the status.
621 sub action_assign_periodic_invoices_config {
624 $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
626 my $config = { active => $::form->{active} ? 1 : 0,
627 terminated => $::form->{terminated} ? 1 : 0,
628 direct_debit => $::form->{direct_debit} ? 1 : 0,
629 periodicity => (any { $_ eq $::form->{periodicity} } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES) ? $::form->{periodicity} : 'm',
630 order_value_periodicity => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
631 start_date_as_date => $::form->{start_date_as_date},
632 end_date_as_date => $::form->{end_date_as_date},
633 first_billing_date_as_date => $::form->{first_billing_date_as_date},
634 print => $::form->{print} ? 1 : 0,
635 printer_id => $::form->{print} ? $::form->{printer_id} * 1 : undef,
636 copies => $::form->{copies} * 1 ? $::form->{copies} : 1,
637 extend_automatically_by => $::form->{extend_automatically_by} * 1 || undef,
638 ar_chart_id => $::form->{ar_chart_id} * 1,
639 send_email => $::form->{send_email} ? 1 : 0,
640 email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
641 email_recipient_address => $::form->{email_recipient_address},
642 email_sender => $::form->{email_sender},
643 email_subject => $::form->{email_subject},
644 email_body => $::form->{email_body},
647 my $periodic_invoices_config = SL::YAML::Dump($config);
649 my $status = $self->get_periodic_invoices_status($config);
652 ->remove('#order_periodic_invoices_config')
653 ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
654 ->run('kivi.Order.close_periodic_invoices_config_dialog')
655 ->html('#periodic_invoices_status', $status)
656 ->flash('info', t8('The periodic invoices config has been assigned.'))
660 sub action_get_has_active_periodic_invoices {
663 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
664 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
666 my $has_active_periodic_invoices =
667 $self->type eq sales_order_type()
670 && (!$config->end_date || ($config->end_date > DateTime->today_local))
671 && $config->get_previous_billed_period_start_date;
673 $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
676 # save the order and redirect to the frontend subroutine for a new
678 sub action_save_and_delivery_order {
681 $self->save_and_redirect_to(
682 controller => 'oe.pl',
683 action => 'oe_delivery_order_from_order',
687 sub action_save_and_supplier_delivery_order {
690 $self->save_and_redirect_to(
691 controller => 'controller.pl',
692 action => 'DeliveryOrder/add_from_order',
693 type => 'supplier_delivery_order',
697 # save the order and redirect to the frontend subroutine for a new
699 sub action_save_and_invoice {
702 $self->save_and_redirect_to(
703 controller => 'oe.pl',
704 action => 'oe_invoice_from_order',
708 sub action_save_and_invoice_for_advance_payment {
711 $self->save_and_redirect_to(
712 controller => 'oe.pl',
713 action => 'oe_invoice_from_order',
714 new_invoice_type => 'invoice_for_advance_payment',
718 sub action_save_and_final_invoice {
721 $self->save_and_redirect_to(
722 controller => 'oe.pl',
723 action => 'oe_invoice_from_order',
724 new_invoice_type => 'final_invoice',
728 # workflow from sales order to sales quotation
729 sub action_sales_quotation {
730 $_[0]->workflow_sales_or_request_for_quotation();
733 # workflow from sales order to sales quotation
734 sub action_request_for_quotation {
735 $_[0]->workflow_sales_or_request_for_quotation();
738 # workflow from sales quotation to sales order
739 sub action_sales_order {
740 $_[0]->workflow_sales_or_purchase_order();
743 # workflow from rfq to purchase order
744 sub action_purchase_order {
745 $_[0]->workflow_sales_or_purchase_order();
748 # workflow from purchase order to ap transaction
749 sub action_save_and_ap_transaction {
752 $self->save_and_redirect_to(
753 controller => 'ap.pl',
754 action => 'add_from_purchase_order',
758 # set form elements in respect to a changed customer or vendor
760 # This action is called on an change of the customer/vendor picker.
761 sub action_customer_vendor_changed {
764 setup_order_from_cv($self->order);
767 my $cv_method = $self->cv;
769 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
770 $self->js->show('#cp_row');
772 $self->js->hide('#cp_row');
775 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
776 $self->js->show('#shipto_selection');
778 $self->js->hide('#shipto_selection');
781 if ($cv_method eq 'customer') {
782 my $show_hide = scalar @{ $self->order->customer->additional_billing_addresses } > 0 ? 'show' : 'hide';
783 $self->js->$show_hide('#billing_address_row');
786 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
789 ->replaceWith('#order_cp_id', $self->build_contact_select)
790 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
791 ->replaceWith('#shipto_inputs ', $self->build_shipto_inputs)
792 ->replaceWith('#order_billing_address_id', $self->build_billing_address_select)
793 ->replaceWith('#business_info_row', $self->build_business_info_row)
794 ->val( '#order_taxzone_id', $self->order->taxzone_id)
795 ->val( '#order_taxincluded', $self->order->taxincluded)
796 ->val( '#order_currency_id', $self->order->currency_id)
797 ->val( '#order_payment_id', $self->order->payment_id)
798 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
799 ->val( '#order_intnotes', $self->order->intnotes)
800 ->val( '#order_language_id', $self->order->$cv_method->language_id)
801 ->focus( '#order_' . $self->cv . '_id')
802 ->run('kivi.Order.update_exchangerate');
804 $self->js_redisplay_amounts_and_taxes;
805 $self->js_redisplay_cvpartnumbers;
809 # open the dialog for customer/vendor details
810 sub action_show_customer_vendor_details_dialog {
813 my $is_customer = 'customer' eq $::form->{vc};
816 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
818 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
821 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
822 $details{discount_as_percent} = $cv->discount_as_percent;
823 $details{creditlimt} = $cv->creditlimit_as_number;
824 $details{business} = $cv->business->description if $cv->business;
825 $details{language} = $cv->language_obj->description if $cv->language_obj;
826 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
827 $details{payment_terms} = $cv->payment->description if $cv->payment;
828 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
831 foreach my $entry (@{ $cv->additional_billing_addresses }) {
832 push @{ $details{ADDITIONAL_BILLING_ADDRESSES} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
835 foreach my $entry (@{ $cv->shipto }) {
836 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
838 foreach my $entry (@{ $cv->contacts }) {
839 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
842 $_[0]->render('common/show_vc_details', { layout => 0 },
843 is_customer => $is_customer,
848 # called if a unit in an existing item row is changed
849 sub action_unit_changed {
852 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
853 my $item = $self->order->items_sorted->[$idx];
855 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
856 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
861 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
862 $self->js_redisplay_line_values;
863 $self->js_redisplay_amounts_and_taxes;
867 # add an item row for a new item entered in the input row
868 sub action_add_item {
871 delete $::form->{add_item}->{create_part_type};
873 my $form_attr = $::form->{add_item};
875 return unless $form_attr->{parts_id};
877 my $item = new_item($self->order, $form_attr);
879 $self->order->add_items($item);
883 $self->get_item_cvpartnumber($item);
885 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
886 my $row_as_html = $self->p->render('order/tabs/_row',
892 if ($::form->{insert_before_item_id}) {
894 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
897 ->append('#row_table_id', $row_as_html);
900 if ( $item->part->is_assortment ) {
901 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
902 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
903 my $attr = { parts_id => $assortment_item->parts_id,
904 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
905 unit => $assortment_item->unit,
906 description => $assortment_item->part->description,
908 my $item = new_item($self->order, $attr);
910 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
911 $item->discount(1) unless $assortment_item->charge;
913 $self->order->add_items( $item );
915 $self->get_item_cvpartnumber($item);
916 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
917 my $row_as_html = $self->p->render('order/tabs/_row',
922 if ($::form->{insert_before_item_id}) {
924 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
927 ->append('#row_table_id', $row_as_html);
933 ->val('.add_item_input', '')
934 ->run('kivi.Order.init_row_handlers')
935 ->run('kivi.Order.renumber_positions')
936 ->focus('#add_item_parts_id_name');
938 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
940 $self->js_redisplay_amounts_and_taxes;
944 # add item rows for multiple items at once
945 sub action_add_multi_items {
948 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
949 return $self->js->render() unless scalar @form_attr;
952 foreach my $attr (@form_attr) {
953 my $item = new_item($self->order, $attr);
955 if ( $item->part->is_assortment ) {
956 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
957 my $attr = { parts_id => $assortment_item->parts_id,
958 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
959 unit => $assortment_item->unit,
960 description => $assortment_item->part->description,
962 my $item = new_item($self->order, $attr);
964 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
965 $item->discount(1) unless $assortment_item->charge;
970 $self->order->add_items(@items);
974 foreach my $item (@items) {
975 $self->get_item_cvpartnumber($item);
976 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
977 my $row_as_html = $self->p->render('order/tabs/_row',
983 if ($::form->{insert_before_item_id}) {
985 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
988 ->append('#row_table_id', $row_as_html);
993 ->run('kivi.Part.close_picker_dialogs')
994 ->run('kivi.Order.init_row_handlers')
995 ->run('kivi.Order.renumber_positions')
996 ->focus('#add_item_parts_id_name');
998 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
1000 $self->js_redisplay_amounts_and_taxes;
1001 $self->js->render();
1004 # recalculate all linetotals, amounts and taxes and redisplay them
1005 sub action_recalc_amounts_and_taxes {
1010 $self->js_redisplay_line_values;
1011 $self->js_redisplay_amounts_and_taxes;
1012 $self->js->render();
1015 sub action_update_exchangerate {
1019 is_standard => $self->order->currency_id == $::instance_conf->get_currency_id,
1020 currency_name => $self->order->currency->name,
1021 exchangerate => $self->order->daily_exchangerate_as_null_number,
1024 $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
1027 # redisplay item rows if they are sorted by an attribute
1028 sub action_reorder_items {
1032 partnumber => sub { $_[0]->part->partnumber },
1033 description => sub { $_[0]->description },
1034 qty => sub { $_[0]->qty },
1035 sellprice => sub { $_[0]->sellprice },
1036 discount => sub { $_[0]->discount },
1037 cvpartnumber => sub { $_[0]->{cvpartnumber} },
1040 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1042 my $method = $sort_keys{$::form->{order_by}};
1043 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
1044 if ($::form->{sort_dir}) {
1045 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
1046 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
1048 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
1051 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
1052 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
1054 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
1058 ->run('kivi.Order.redisplay_items', \@to_sort)
1062 # show the popup to choose a price/discount source
1063 sub action_price_popup {
1066 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
1067 my $item = $self->order->items_sorted->[$idx];
1069 $self->render_price_dialog($item);
1072 # save the order in a session variable and redirect to the part controller
1073 sub action_create_part {
1076 my $previousform = $::auth->save_form_in_session(non_scalars => 1);
1078 my $callback = $self->url_for(
1079 action => 'return_from_create_part',
1080 type => $self->type, # type is needed for check_auth on return
1081 previousform => $previousform,
1084 flash_later('info', t8('You are adding a new part while you are editing another document. You will be redirected to your document when saving the new part or aborting this form.'));
1086 my @redirect_params = (
1087 controller => 'Part',
1089 part_type => $::form->{add_item}->{create_part_type},
1090 callback => $callback,
1094 $self->redirect_to(@redirect_params);
1097 sub action_return_from_create_part {
1100 $self->{created_part} = SL::DB::Part->new(id => delete $::form->{new_parts_id})->load if $::form->{new_parts_id};
1102 $::auth->restore_form_from_session(delete $::form->{previousform});
1104 # set item ids to new fake id, to identify them as new items
1105 foreach my $item (@{$self->order->items_sorted}) {
1106 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1110 $self->get_unalterable_data();
1111 $self->pre_render();
1113 # trigger rendering values for second row/longdescription as hidden,
1114 # because they are loaded only on demand. So we need to keep the values
1116 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1117 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
1121 title => $self->get_title_for('edit'),
1122 %{$self->{template_args}}
1127 # load the second row for one or more items
1129 # This action gets the html code for all items second rows by rendering a template for
1130 # the second row and sets the html code via client js.
1131 sub action_load_second_rows {
1134 $self->recalc() if $self->order->is_sales; # for margin calculation
1136 foreach my $item_id (@{ $::form->{item_ids} }) {
1137 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1138 my $item = $self->order->items_sorted->[$idx];
1140 $self->js_load_second_row($item, $item_id, 0);
1143 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
1145 $self->js->render();
1148 # update description, notes and sellprice from master data
1149 sub action_update_row_from_master_data {
1152 foreach my $item_id (@{ $::form->{item_ids} }) {
1153 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1154 my $item = $self->order->items_sorted->[$idx];
1155 my $texts = get_part_texts($item->part, $self->order->language_id);
1157 $item->description($texts->{description});
1158 $item->longdescription($texts->{longdescription});
1160 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1163 if ($item->part->is_assortment) {
1164 # add assortment items with price 0, as the components carry the price
1165 $price_src = $price_source->price_from_source("");
1166 $price_src->price(0);
1168 $price_src = $price_source->best_price
1169 ? $price_source->best_price
1170 : $price_source->price_from_source("");
1171 $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate;
1172 $price_src->price(0) if !$price_source->best_price;
1176 $item->sellprice($price_src->price);
1177 $item->active_price_source($price_src);
1180 ->run('kivi.Order.update_sellprice', $item_id, $item->sellprice_as_number)
1181 ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
1182 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
1183 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
1185 if ($self->search_cvpartnumber) {
1186 $self->get_item_cvpartnumber($item);
1187 $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
1192 $self->js_redisplay_line_values;
1193 $self->js_redisplay_amounts_and_taxes;
1195 $self->js->render();
1198 sub js_load_second_row {
1199 my ($self, $item, $item_id, $do_parse) = @_;
1202 # Parse values from form (they are formated while rendering (template)).
1203 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1204 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1205 foreach my $var (@{ $item->cvars_by_config }) {
1206 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1208 $item->parse_custom_variable_values;
1211 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1214 ->html('#second_row_' . $item_id, $row_as_html)
1215 ->data('#second_row_' . $item_id, 'loaded', 1);
1218 sub js_redisplay_line_values {
1221 my $is_sales = $self->order->is_sales;
1223 # sales orders with margins
1228 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1229 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1230 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1231 ]} @{ $self->order->items_sorted };
1235 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1236 ]} @{ $self->order->items_sorted };
1240 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1243 sub js_redisplay_amounts_and_taxes {
1246 if (scalar @{ $self->{taxes} }) {
1247 $self->js->show('#taxincluded_row_id');
1249 $self->js->hide('#taxincluded_row_id');
1252 if ($self->order->taxincluded) {
1253 $self->js->hide('#subtotal_row_id');
1255 $self->js->show('#subtotal_row_id');
1258 if ($self->order->is_sales) {
1259 my $is_neg = $self->order->marge_total < 0;
1261 ->html('#marge_total_id', $::form->format_amount(\%::myconfig, $self->order->marge_total, 2))
1262 ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
1263 ->action_if( $is_neg, 'addClass', '#marge_total_id', 'plus0')
1264 ->action_if( $is_neg, 'addClass', '#marge_percent_id', 'plus0')
1265 ->action_if( $is_neg, 'addClass', '#marge_percent_sign_id', 'plus0')
1266 ->action_if(!$is_neg, 'removeClass', '#marge_total_id', 'plus0')
1267 ->action_if(!$is_neg, 'removeClass', '#marge_percent_id', 'plus0')
1268 ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
1272 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
1273 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
1274 ->remove('.tax_row')
1275 ->insertBefore($self->build_tax_rows, '#amount_row_id');
1278 sub js_redisplay_cvpartnumbers {
1281 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1283 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1286 ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
1289 sub js_reset_order_and_item_ids_after_save {
1293 ->val('#id', $self->order->id)
1294 ->val('#converted_from_oe_id', '')
1295 ->val('#order_' . $self->nr_key(), $self->order->number);
1298 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1299 next if !$self->order->items_sorted->[$idx]->id;
1300 next if $form_item_id !~ m{^new};
1302 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1303 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1304 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1308 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1315 sub init_valid_types {
1316 [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
1322 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1323 die "Not a valid type for order";
1326 $self->type($::form->{type});
1332 my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer'
1333 : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
1334 : die "Not a valid type for order";
1339 sub init_search_cvpartnumber {
1342 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1343 my $search_cvpartnumber;
1344 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1345 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1347 return $search_cvpartnumber;
1350 sub init_show_update_button {
1353 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1364 sub init_all_price_factors {
1365 SL::DB::Manager::PriceFactor->get_all;
1368 sub init_part_picker_classification_ids {
1370 my $attribute = 'used_for_' . ($self->type =~ m{sales} ? 'sale' : 'purchase');
1372 return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => [ $attribute => 1 ]) } ];
1378 my $right_for = { map { $_ => $_.'_edit' . ' | ' . $_.'_view' } @{$self->valid_types} };
1380 my $right = $right_for->{ $self->type };
1381 $right ||= 'DOES_NOT_EXIST';
1383 $::auth->assert($right);
1386 sub check_auth_for_edit {
1389 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
1391 my $right = $right_for->{ $self->type };
1392 $right ||= 'DOES_NOT_EXIST';
1394 $::auth->assert($right);
1397 # build the selection box for contacts
1399 # Needed, if customer/vendor changed.
1400 sub build_contact_select {
1403 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1404 value_key => 'cp_id',
1405 title_key => 'full_name_dep',
1406 default => $self->order->cp_id,
1408 style => 'width: 300px',
1412 # build the selection box for the additional billing address
1414 # Needed, if customer/vendor changed.
1415 sub build_billing_address_select {
1418 return '' if $self->cv ne 'customer';
1420 select_tag('order.billing_address_id',
1421 [ {displayable_id => '', id => ''}, $self->order->{$self->cv}->additional_billing_addresses ],
1423 title_key => 'displayable_id',
1424 default => $self->order->billing_address_id,
1426 style => 'width: 300px',
1430 # build the selection box for shiptos
1432 # Needed, if customer/vendor changed.
1433 sub build_shipto_select {
1436 select_tag('order.shipto_id',
1437 [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
1438 value_key => 'shipto_id',
1439 title_key => 'displayable_id',
1440 default => $self->order->shipto_id,
1442 style => 'width: 300px',
1446 # build the inputs for the cusom shipto dialog
1448 # Needed, if customer/vendor changed.
1449 sub build_shipto_inputs {
1452 my $content = $self->p->render('common/_ship_to_dialog',
1453 vc_obj => $self->order->customervendor,
1454 cs_obj => $self->order->custom_shipto,
1455 cvars => $self->order->custom_shipto->cvars_by_config,
1456 id_selector => '#order_shipto_id');
1458 div_tag($content, id => 'shipto_inputs');
1461 # render the info line for business
1463 # Needed, if customer/vendor changed.
1464 sub build_business_info_row
1466 $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
1469 # build the rows for displaying taxes
1471 # Called if amounts where recalculated and redisplayed.
1472 sub build_tax_rows {
1476 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
1477 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
1479 return $rows_as_html;
1483 sub render_price_dialog {
1484 my ($self, $record_item) = @_;
1486 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1490 'kivi.io.price_chooser_dialog',
1491 t8('Available Prices'),
1492 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1497 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1498 # $self->js->show('#dialog_flash_error');
1507 return if !$::form->{id};
1509 $self->order(SL::DB::Order->new(id => $::form->{id})->load);
1511 # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
1512 # You need a custom shipto object to call cvars_by_config to get the cvars.
1513 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
1515 return $self->order;
1518 # load or create a new order object
1520 # And assign changes from the form to this object.
1521 # If the order is loaded from db, check if items are deleted in the form,
1522 # remove them form the object and collect them for removing from db on saving.
1523 # Then create/update items from form (via make_item) and add them.
1527 # add_items adds items to an order with no items for saving, but they cannot
1528 # be retrieved via items until the order is saved. Adding empty items to new
1529 # order here solves this problem.
1531 $order = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1532 $order ||= SL::DB::Order->new(orderitems => [],
1533 quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())),
1534 currency_id => $::instance_conf->get_currency_id(),);
1536 my $cv_id_method = $self->cv . '_id';
1537 if (!$::form->{id} && $::form->{$cv_id_method}) {
1538 $order->$cv_id_method($::form->{$cv_id_method});
1539 setup_order_from_cv($order);
1542 my $form_orderitems = delete $::form->{order}->{orderitems};
1543 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1545 $order->assign_attributes(%{$::form->{order}});
1547 $self->setup_custom_shipto_from_form($order, $::form);
1549 if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) {
1550 my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
1551 $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
1554 # remove deleted items
1555 $self->item_ids_to_delete([]);
1556 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1557 my $item = $order->orderitems->[$idx];
1558 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1559 splice @{$order->orderitems}, $idx, 1;
1560 push @{$self->item_ids_to_delete}, $item->id;
1566 foreach my $form_attr (@{$form_orderitems}) {
1567 my $item = make_item($order, $form_attr);
1568 $item->position($pos);
1572 $order->add_items(grep {!$_->id} @items);
1577 # create or update items from form
1579 # Make item objects from form values. For items already existing read from db.
1580 # Create a new item else. And assign attributes.
1582 my ($record, $attr) = @_;
1585 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1587 my $is_new = !$item;
1589 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1590 # they cannot be retrieved via custom_variables until the order/orderitem is
1591 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1592 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1594 $item->assign_attributes(%$attr);
1597 my $texts = get_part_texts($item->part, $record->language_id);
1598 $item->longdescription($texts->{longdescription}) if !defined $attr->{longdescription};
1599 $item->project_id($record->globalproject_id) if !defined $attr->{project_id};
1600 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number};
1608 # This is used to add one item
1610 my ($record, $attr) = @_;
1612 my $item = SL::DB::OrderItem->new;
1614 # Remove attributes where the user left or set the inputs empty.
1615 # So these attributes will be undefined and we can distinguish them
1616 # from zero later on.
1617 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1618 delete $attr->{$_} if $attr->{$_} eq '';
1621 $item->assign_attributes(%$attr);
1623 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1624 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1626 $item->unit($part->unit) if !$item->unit;
1629 if ( $part->is_assortment ) {
1630 # add assortment items with price 0, as the components carry the price
1631 $price_src = $price_source->price_from_source("");
1632 $price_src->price(0);
1633 } elsif (defined $item->sellprice) {
1634 $price_src = $price_source->price_from_source("");
1635 $price_src->price($item->sellprice);
1637 $price_src = $price_source->best_price
1638 ? $price_source->best_price
1639 : $price_source->price_from_source("");
1640 $price_src->price($::form->round_amount($price_src->price / $record->exchangerate, 5)) if $record->exchangerate;
1641 $price_src->price(0) if !$price_source->best_price;
1645 if (defined $item->discount) {
1646 $discount_src = $price_source->discount_from_source("");
1647 $discount_src->discount($item->discount);
1649 $discount_src = $price_source->best_discount
1650 ? $price_source->best_discount
1651 : $price_source->discount_from_source("");
1652 $discount_src->discount(0) if !$price_source->best_discount;
1656 $new_attr{part} = $part;
1657 $new_attr{description} = $part->description if ! $item->description;
1658 $new_attr{qty} = 1.0 if ! $item->qty;
1659 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1660 $new_attr{sellprice} = $price_src->price;
1661 $new_attr{discount} = $discount_src->discount;
1662 $new_attr{active_price_source} = $price_src;
1663 $new_attr{active_discount_source} = $discount_src;
1664 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1665 $new_attr{project_id} = $record->globalproject_id;
1666 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1668 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1669 # they cannot be retrieved via custom_variables until the order/orderitem is
1670 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1671 $new_attr{custom_variables} = [];
1673 my $texts = get_part_texts($part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription});
1675 $item->assign_attributes(%new_attr, %{ $texts });
1680 sub setup_order_from_cv {
1683 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id language_id));
1685 $order->intnotes($order->customervendor->notes);
1687 return if !$order->is_sales;
1689 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1690 $order->taxincluded(defined($order->customer->taxincluded_checked)
1691 ? $order->customer->taxincluded_checked
1692 : $::myconfig{taxincluded_checked});
1694 my $address = $order->customer->default_billing_address;;
1695 $order->billing_address_id($address ? $address->id : undef);
1698 # setup custom shipto from form
1700 # The dialog returns form variables starting with 'shipto' and cvars starting
1701 # with 'shiptocvar_'.
1702 # Mark it to be deleted if a shipto from master data is selected
1703 # (i.e. order has a shipto).
1704 # Else, update or create a new custom shipto. If the fields are empty, it
1705 # will not be saved on save.
1706 sub setup_custom_shipto_from_form {
1707 my ($self, $order, $form) = @_;
1709 if ($order->shipto) {
1710 $self->is_custom_shipto_to_delete(1);
1712 my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1714 my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
1715 my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
1717 $custom_shipto->assign_attributes(%$shipto_attrs);
1718 $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
1722 # recalculate prices and taxes
1724 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1728 my %pat = $self->order->calculate_prices_and_taxes();
1730 $self->{taxes} = [];
1731 foreach my $tax_id (keys %{ $pat{taxes_by_tax_id} }) {
1732 my $netamount = sum0 map { $pat{amounts}->{$_}->{amount} } grep { $pat{amounts}->{$_}->{tax_id} == $tax_id } keys %{ $pat{amounts} };
1734 push(@{ $self->{taxes} }, { amount => $pat{taxes_by_tax_id}->{$tax_id},
1735 netamount => $netamount,
1736 tax => SL::DB::Tax->new(id => $tax_id)->load });
1738 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items_sorted}, @{$pat{items}};
1741 # get data for saving, printing, ..., that is not changed in the form
1743 # Only cvars for now.
1744 sub get_unalterable_data {
1747 foreach my $item (@{ $self->order->items }) {
1748 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1749 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1750 foreach my $var (@{ $item->cvars_by_config }) {
1751 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1753 $item->parse_custom_variable_values;
1759 # And remove related files in the spool directory
1764 my $db = $self->order->db;
1766 $db->with_transaction(
1768 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1769 $self->order->delete;
1770 my $spool = $::lx_office_conf{paths}->{spool};
1771 unlink map { "$spool/$_" } @spoolfiles if $spool;
1773 $self->save_history('DELETED');
1776 }) || push(@{$errors}, $db->error);
1783 # And delete items that are deleted in the form.
1788 my $db = $self->order->db;
1790 $db->with_transaction(sub {
1791 # delete custom shipto if it is to be deleted or if it is empty
1792 if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
1793 $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
1794 $self->order->custom_shipto(undef);
1797 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1798 $self->order->save(cascade => 1);
1801 if ($::form->{converted_from_oe_id}) {
1802 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1804 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1805 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1806 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
1807 $src->link_to_record($self->order);
1809 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1811 foreach (@{ $self->order->items_sorted }) {
1812 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1814 SL::DB::RecordLink->new(from_table => 'orderitems',
1815 from_id => $from_id,
1816 to_table => 'orderitems',
1823 $self->link_requirement_specs_linking_to_created_from_objects(@converted_from_oe_ids);
1826 $self->set_project_in_linked_requirement_specs if $self->order->globalproject_id;
1828 $self->save_history('SAVED');
1831 }) || push(@{$errors}, $db->error);
1836 sub workflow_sales_or_request_for_quotation {
1840 my $errors = $self->save();
1842 if (scalar @{ $errors }) {
1843 $self->js->flash('error', $_) for @{ $errors };
1844 return $self->js->render();
1847 my $destination_type = $::form->{type} eq sales_order_type() ? sales_quotation_type() : request_quotation_type();
1849 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1850 delete $::form->{id};
1852 # no linked records from order to quotations
1853 delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
1855 # set item ids to new fake id, to identify them as new items
1856 foreach my $item (@{$self->order->items_sorted}) {
1857 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1861 $::form->{type} = $destination_type;
1862 $self->type($self->init_type);
1863 $self->cv ($self->init_cv);
1867 $self->get_unalterable_data();
1868 $self->pre_render();
1870 # trigger rendering values for second row as hidden, because they
1871 # are loaded only on demand. So we need to keep the values from the
1873 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1877 title => $self->get_title_for('edit'),
1878 %{$self->{template_args}}
1882 sub workflow_sales_or_purchase_order {
1886 my $errors = $self->save();
1888 if (scalar @{ $errors }) {
1889 $self->js->flash('error', $_) foreach @{ $errors };
1890 return $self->js->render();
1893 my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type()
1894 : $::form->{type} eq request_quotation_type() ? purchase_order_type()
1895 : $::form->{type} eq purchase_order_type() ? sales_order_type()
1896 : $::form->{type} eq sales_order_type() ? purchase_order_type()
1899 # check for direct delivery
1900 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
1902 if ( $::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()
1903 && $::form->{use_shipto} && $self->order->shipto) {
1904 $custom_shipto = $self->order->shipto->clone('SL::DB::Order');
1907 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1908 $self->{converted_from_oe_id} = delete $::form->{id};
1910 # set item ids to new fake id, to identify them as new items
1911 foreach my $item (@{$self->order->items_sorted}) {
1912 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1915 if ($::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()) {
1916 if ($::form->{use_shipto}) {
1917 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
1919 # remove any custom shipto if not wanted
1920 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1925 $::form->{type} = $destination_type;
1926 $self->type($self->init_type);
1927 $self->cv ($self->init_cv);
1931 $self->get_unalterable_data();
1932 $self->pre_render();
1934 # trigger rendering values for second row as hidden, because they
1935 # are loaded only on demand. So we need to keep the values from the
1937 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1941 title => $self->get_title_for('edit'),
1942 %{$self->{template_args}}
1950 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1951 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
1952 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1953 $self->{all_languages} = SL::DB::Manager::Language->get_all_sorted();
1954 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1957 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1960 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1962 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1963 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1964 $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
1965 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1966 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1968 my $print_form = Form->new('');
1969 $print_form->{type} = $self->type;
1970 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1971 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1972 form => $print_form,
1973 options => {dialog_name_prefix => 'print_options.',
1977 no_opendocument => 0,
1981 foreach my $item (@{$self->order->orderitems}) {
1982 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1983 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1984 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1987 if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
1988 # Calculate shipped qtys here to prevent calling calculate for every item via the items method.
1989 # Do not use write_to_objects to prevent order->delivered to be set, because this should be
1990 # the value from db, which can be set manually or is set when linked delivery orders are saved.
1991 SL::Helper::ShippedQty->new->calculate($self->order)->write_to(\@{$self->order->items});
1994 if ($self->order->number && $::instance_conf->get_webdav) {
1995 my $webdav = SL::Webdav->new(
1996 type => $self->type,
1997 number => $self->order->number,
1999 my @all_objects = $webdav->get_all_objects;
2000 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
2002 link => File::Spec->catfile($_->full_filedescriptor),
2006 if ( (any { $self->type eq $_ } (sales_quotation_type(), sales_order_type()))
2007 && $::instance_conf->get_transport_cost_reminder_article_number_id ) {
2008 $self->{template_args}->{transport_cost_reminder_article} = SL::DB::Part->new(id => $::instance_conf->get_transport_cost_reminder_article_number_id)->load;
2011 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
2013 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.Validator kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
2014 edit_periodic_invoices_config calculate_qty follow_up show_history);
2015 $self->setup_edit_action_bar;
2018 sub setup_edit_action_bar {
2019 my ($self, %params) = @_;
2021 my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
2022 || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
2023 || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
2025 my @req_trans_cost_art = qw(kivi.Order.check_transport_cost_article_presence) x!!$::instance_conf->get_transport_cost_reminder_article_number_id;
2026 my @req_cusordnumber = qw(kivi.Order.check_cusordnumber_presence) x($self->type eq sales_order_type() && $::instance_conf->get_order_warn_no_cusordnumber);
2028 my $has_invoice_for_advance_payment;
2029 if ($self->order->id && $self->type eq sales_order_type()) {
2030 my $lr = $self->order->linked_records(direction => 'to', to => ['Invoice']);
2031 $has_invoice_for_advance_payment = any {'SL::DB::Invoice' eq ref $_ && "invoice_for_advance_payment" eq $_->type} @$lr;
2034 my $has_final_invoice;
2035 if ($self->order->id && $self->type eq sales_order_type()) {
2036 my $lr = $self->order->linked_records(direction => 'to', to => ['Invoice']);
2037 $has_final_invoice = any {'SL::DB::Invoice' eq ref $_ && "final_invoice" eq $_->type} @$lr;
2040 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
2041 my $right = $right_for->{ $self->type };
2042 $right ||= 'DOES_NOT_EXIST';
2043 my $may_edit_create = $::auth->assert($right, 'may fail');
2045 for my $bar ($::request->layout->get('actionbar')) {
2050 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
2051 $::instance_conf->get_order_warn_no_deliverydate,
2053 checks => [ 'kivi.Order.check_save_active_periodic_invoices', ['kivi.validate_form','#order_form'],
2054 @req_trans_cost_art, @req_cusordnumber,
2056 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2060 call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
2061 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2062 @req_trans_cost_art, @req_cusordnumber,
2064 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
2065 : !$self->order->id ? t8('This object has not been saved yet.')
2068 ], # end of combobox "Save"
2075 t8('Save and Quotation'),
2076 submit => [ '#order_form', { action => "Order/sales_quotation" } ],
2077 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2078 only_if => (any { $self->type eq $_ } (sales_order_type())),
2079 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2083 submit => [ '#order_form', { action => "Order/request_for_quotation" } ],
2084 only_if => (any { $self->type eq $_ } (purchase_order_type())),
2085 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2088 t8('Save and Sales Order'),
2089 submit => [ '#order_form', { action => "Order/sales_order" } ],
2090 checks => [ @req_trans_cost_art ],
2091 only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
2092 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2095 t8('Save and Purchase Order'),
2096 call => [ 'kivi.Order.purchase_order_check_for_direct_delivery' ],
2097 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2098 only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
2099 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2102 t8('Save and Delivery Order'),
2103 call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
2104 $::instance_conf->get_order_warn_no_deliverydate,
2106 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2107 @req_trans_cost_art, @req_cusordnumber,
2109 only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())),
2110 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2113 t8('Save and Supplier Delivery Order'),
2114 call => [ 'kivi.Order.save', 'save_and_supplier_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
2115 $::instance_conf->get_order_warn_no_deliverydate,
2117 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2118 @req_trans_cost_art, @req_cusordnumber,
2120 only_if => (any { $self->type eq $_ } (purchase_order_type())),
2121 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2124 t8('Save and Invoice'),
2125 call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
2126 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2127 @req_trans_cost_art, @req_cusordnumber,
2129 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2132 ($has_invoice_for_advance_payment ? t8('Save and Further Invoice for Advance Payment') : t8('Save and Invoice for Advance Payment')),
2133 call => [ 'kivi.Order.save', 'save_and_invoice_for_advance_payment', $::instance_conf->get_order_warn_duplicate_parts ],
2134 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2135 @req_trans_cost_art, @req_cusordnumber,
2137 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
2138 : $has_final_invoice ? t8('This order has already a final invoice.')
2140 only_if => (any { $self->type eq $_ } (sales_order_type())),
2143 t8('Save and Final Invoice'),
2144 call => [ 'kivi.Order.save', 'save_and_final_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
2145 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2146 @req_trans_cost_art, @req_cusordnumber,
2148 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
2149 : $has_final_invoice ? t8('This order has already a final invoice.')
2151 only_if => (any { $self->type eq $_ } (sales_order_type())) && $has_invoice_for_advance_payment,
2154 t8('Save and AP Transaction'),
2155 call => [ 'kivi.Order.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
2156 only_if => (any { $self->type eq $_ } (purchase_order_type())),
2157 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2160 ], # end of combobox "Workflow"
2167 t8('Save and preview PDF'),
2168 call => [ 'kivi.Order.save', 'preview_pdf', $::instance_conf->get_order_warn_duplicate_parts,
2169 $::instance_conf->get_order_warn_no_deliverydate,
2171 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2172 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2175 t8('Save and print'),
2176 call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts,
2177 $::instance_conf->get_order_warn_no_deliverydate,
2179 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2180 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2183 t8('Save and E-mail'),
2184 id => 'save_and_email_action',
2185 call => [ 'kivi.Order.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts,
2186 $::instance_conf->get_order_warn_no_deliverydate,
2188 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
2189 : !$self->order->id ? t8('This object has not been saved yet.')
2193 t8('Download attachments of all parts'),
2194 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
2195 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
2196 only_if => $::instance_conf->get_doc_storage,
2198 ], # end of combobox "Export"
2202 call => [ 'kivi.Order.delete_order' ],
2203 confirm => $::locale->text('Do you really want to delete this object?'),
2204 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
2205 : !$self->order->id ? t8('This object has not been saved yet.')
2207 only_if => $deletion_allowed,
2215 my ($self, $doc_ref, $params) = @_;
2217 my $order = $self->order;
2220 my $print_form = Form->new('');
2221 $print_form->{type} = $order->type;
2222 $print_form->{formname} = $params->{formname} || $order->type;
2223 $print_form->{format} = $params->{format} || 'pdf';
2224 $print_form->{media} = $params->{media} || 'file';
2225 $print_form->{groupitems} = $params->{groupitems};
2226 $print_form->{printer_id} = $params->{printer_id};
2227 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
2229 $order->language($params->{language});
2230 $order->flatten_to_form($print_form, format_amounts => 1);
2234 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
2235 $template_ext = 'odt';
2236 $template_type = 'OpenDocument';
2237 } elsif ($print_form->{format} =~ m{html}i) {
2238 $template_ext = 'html';
2239 $template_type = 'HTML';
2242 # search for the template
2243 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
2244 name => $print_form->{formname},
2245 extension => $template_ext,
2246 email => $print_form->{media} eq 'email',
2247 language => $params->{language},
2248 printer_id => $print_form->{printer_id},
2251 if (!defined $template_file) {
2252 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);
2255 return @errors if scalar @errors;
2257 $print_form->throw_on_error(sub {
2259 $print_form->prepare_for_printing;
2261 $$doc_ref = SL::Helper::CreatePDF->create_pdf(
2262 format => $print_form->{format},
2263 template_type => $template_type,
2264 template => $template_file,
2265 variables => $print_form,
2266 variable_content_types => {
2267 longdescription => 'html',
2268 partnotes => 'html',
2270 $::form->get_variable_content_types_for_cvars,
2274 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
2280 sub get_files_for_email_dialog {
2283 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
2285 return %files if !$::instance_conf->get_doc_storage;
2287 if ($self->order->id) {
2288 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
2289 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
2290 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
2291 $files{project_files} = [ SL::File->get_all( object_id => $self->order->globalproject_id, object_type => 'project', file_type => 'attachment') ];
2295 uniq_by { $_->{id} }
2297 +{ id => $_->part->id,
2298 partnumber => $_->part->partnumber }
2299 } @{$self->order->items_sorted};
2301 foreach my $part (@parts) {
2302 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
2303 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
2306 foreach my $key (keys %files) {
2307 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
2313 sub make_periodic_invoices_config_from_yaml {
2314 my ($yaml_config) = @_;
2316 return if !$yaml_config;
2317 my $attr = SL::YAML::Load($yaml_config);
2318 return if 'HASH' ne ref $attr;
2319 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
2323 sub get_periodic_invoices_status {
2324 my ($self, $config) = @_;
2326 return if $self->type ne sales_order_type();
2327 return t8('not configured') if !$config;
2329 my $active = ('HASH' eq ref $config) ? $config->{active}
2330 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
2331 : die "Cannot get status of periodic invoices config";
2333 return $active ? t8('active') : t8('inactive');
2337 my ($self, $action) = @_;
2339 return '' if none { lc($action)} qw(add edit);
2342 # $::locale->text("Add Sales Order");
2343 # $::locale->text("Add Purchase Order");
2344 # $::locale->text("Add Quotation");
2345 # $::locale->text("Add Request for Quotation");
2346 # $::locale->text("Edit Sales Order");
2347 # $::locale->text("Edit Purchase Order");
2348 # $::locale->text("Edit Quotation");
2349 # $::locale->text("Edit Request for Quotation");
2351 $action = ucfirst(lc($action));
2352 return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order")
2353 : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order")
2354 : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation")
2355 : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
2359 sub get_item_cvpartnumber {
2360 my ($self, $item) = @_;
2362 return if !$self->search_cvpartnumber;
2363 return if !$self->order->customervendor;
2365 if ($self->cv eq 'vendor') {
2366 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
2367 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
2368 } elsif ($self->cv eq 'customer') {
2369 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
2370 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
2374 sub get_part_texts {
2375 my ($part_or_id, $language_or_id, %defaults) = @_;
2377 my $part = ref($part_or_id) ? $part_or_id : SL::DB::Part->load_cached($part_or_id);
2378 my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id;
2380 description => $defaults{description} // $part->description,
2381 longdescription => $defaults{longdescription} // $part->notes,
2384 return $texts unless $language_id;
2386 my $translation = SL::DB::Manager::Translation->get_first(
2388 parts_id => $part->id,
2389 language_id => $language_id,
2392 $texts->{description} = $translation->translation if $translation && $translation->translation;
2393 $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription;
2398 sub sales_order_type {
2402 sub purchase_order_type {
2406 sub sales_quotation_type {
2410 sub request_quotation_type {
2411 'request_quotation';
2415 return $_[0]->type eq sales_order_type() ? 'ordnumber'
2416 : $_[0]->type eq purchase_order_type() ? 'ordnumber'
2417 : $_[0]->type eq sales_quotation_type() ? 'quonumber'
2418 : $_[0]->type eq request_quotation_type() ? 'quonumber'
2422 sub save_and_redirect_to {
2423 my ($self, %params) = @_;
2425 my $errors = $self->save();
2427 if (scalar @{ $errors }) {
2428 $self->js->flash('error', $_) foreach @{ $errors };
2429 return $self->js->render();
2432 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
2433 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
2434 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
2435 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
2437 flash_later('info', $text);
2439 $self->redirect_to(%params, id => $self->order->id);
2443 my ($self, $addition) = @_;
2445 my $number_type = $self->order->type =~ m{order} ? 'ordnumber' : 'quonumber';
2446 my $snumbers = $number_type . '_' . $self->order->$number_type;
2448 SL::DB::History->new(
2449 trans_id => $self->order->id,
2450 employee_id => SL::DB::Manager::Employee->current->id,
2451 what_done => $self->order->type,
2452 snumbers => $snumbers,
2453 addition => $addition,
2457 sub store_doc_to_webdav_and_filemanagement {
2458 my ($self, $content, $filename, $variant) = @_;
2460 my $order = $self->order;
2463 # copy file to webdav folder
2464 if ($order->number && $::instance_conf->get_webdav_documents) {
2465 my $webdav = SL::Webdav->new(
2466 type => $order->type,
2467 number => $order->number,
2469 my $webdav_file = SL::Webdav::File->new(
2471 filename => $filename,
2474 $webdav_file->store(data => \$content);
2477 push @errors, t8('Storing the document to the WebDAV folder failed: #1', $@);
2480 if ($order->id && $::instance_conf->get_doc_storage) {
2482 SL::File->save(object_id => $order->id,
2483 object_type => $order->type,
2484 mime_type => SL::MIME->mime_type_from_ext($filename),
2485 source => 'created',
2486 file_type => 'document',
2487 file_name => $filename,
2488 file_contents => $content,
2489 print_variant => $variant);
2492 push @errors, t8('Storing the document in the storage backend failed: #1', $@);
2499 sub link_requirement_specs_linking_to_created_from_objects {
2500 my ($self, @converted_from_oe_ids) = @_;
2502 return unless @converted_from_oe_ids;
2504 my $rs_orders = SL::DB::Manager::RequirementSpecOrder->get_all(where => [ order_id => \@converted_from_oe_ids ]);
2505 foreach my $rs_order (@{ $rs_orders }) {
2506 SL::DB::RequirementSpecOrder->new(
2507 order_id => $self->order->id,
2508 requirement_spec_id => $rs_order->requirement_spec_id,
2509 version_id => $rs_order->version_id,
2514 sub set_project_in_linked_requirement_specs {
2517 my $rs_orders = SL::DB::Manager::RequirementSpecOrder->get_all(where => [ order_id => $self->order->id ]);
2518 foreach my $rs_order (@{ $rs_orders }) {
2519 next if $rs_order->requirement_spec->project_id == $self->order->globalproject_id;
2521 $rs_order->requirement_spec->update_attributes(project_id => $self->order->globalproject_id);
2533 SL::Controller::Order - controller for orders
2537 This is a new form to enter orders, completely rewritten with the use
2538 of controller and java script techniques.
2540 The aim is to provide the user a better experience and a faster workflow. Also
2541 the code should be more readable, more reliable and better to maintain.
2549 One input row, so that input happens every time at the same place.
2553 Use of pickers where possible.
2557 Possibility to enter more than one item at once.
2561 Item list in a scrollable area, so that the workflow buttons stay at
2566 Reordering item rows with drag and drop is possible. Sorting item rows is
2567 possible (by partnumber, description, qty, sellprice and discount for now).
2571 No C<update> is necessary. All entries and calculations are managed
2572 with ajax-calls and the page only reloads on C<save>.
2576 User can see changes immediately, because of the use of java script
2587 =item * C<SL/Controller/Order.pm>
2591 =item * C<template/webpages/order/form.html>
2595 =item * C<template/webpages/order/tabs/basic_data.html>
2597 Main tab for basic_data.
2599 This is the only tab here for now. "linked records" and "webdav" tabs are
2600 reused from generic code.
2604 =item * C<template/webpages/order/tabs/_business_info_row.html>
2606 For displaying information on business type
2608 =item * C<template/webpages/order/tabs/_item_input.html>
2610 The input line for items
2612 =item * C<template/webpages/order/tabs/_row.html>
2614 One row for already entered items
2616 =item * C<template/webpages/order/tabs/_tax_row.html>
2618 Displaying tax information
2620 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
2622 Dialog for selecting price and discount sources
2626 =item * C<js/kivi.Order.js>
2628 java script functions
2638 =item * price sources: little symbols showing better price / better discount
2640 =item * select units in input row?
2642 =item * check for direct delivery (workflow sales order -> purchase order)
2644 =item * access rights
2646 =item * display weights
2650 =item * optional client/user behaviour
2652 (transactions has to be set - department has to be set -
2653 force project if enabled in client config)
2657 =head1 KNOWN BUGS AND CAVEATS
2663 Customer discount is not displayed as a valid discount in price source popup
2664 (this might be a bug in price sources)
2666 (I cannot reproduce this (Bernd))
2670 No indication that <shift>-up/down expands/collapses second row.
2674 Inline creation of parts is not currently supported
2678 Table header is not sticky in the scrolling area.
2682 Sorting does not include C<position>, neither does reordering.
2684 This behavior was implemented intentionally. But we can discuss, which behavior
2685 should be implemented.
2689 =head1 To discuss / Nice to have
2695 How to expand/collapse second row. Now it can be done clicking the icon or
2700 Possibility to select PriceSources in input row?
2704 This controller uses a (changed) copy of the template for the PriceSource
2705 dialog. Maybe there could be used one code source.
2709 Rounding-differences between this controller (PriceTaxCalculator) and the old
2710 form. This is not only a problem here, but also in all parts using the PTC.
2711 There exists a ticket and a patch. This patch should be testet.
2715 An indicator, if the actual inputs are saved (like in an
2716 editor or on text processing application).
2720 A warning when leaving the page without saveing unchanged inputs.
2727 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>