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;
29 use SL::DB::RecordLink;
30 use SL::DB::RequirementSpec;
32 use SL::DB::Translation;
34 use SL::Helper::CreatePDF qw(:all);
35 use SL::Helper::PrintOptions;
36 use SL::Helper::ShippedQty;
37 use SL::Helper::UserPreferences::DisplayPreferences;
38 use SL::Helper::UserPreferences::PositionsScrollbar;
39 use SL::Helper::UserPreferences::UpdatePositions;
41 use SL::Controller::Helper::GetModels;
43 use List::Util qw(first sum0);
44 use List::UtilsBy qw(sort_by uniq_by);
45 use List::MoreUtils qw(any none pairwise first_index);
46 use English qw(-no_match_vars);
51 use Rose::Object::MakeMethods::Generic
53 scalar => [ qw(item_ids_to_delete is_custom_shipto_to_delete) ],
54 'scalar --get_set_init' => [ qw(order valid_types type cv p all_price_factors search_cvpartnumber show_update_button part_picker_classification_ids) ],
59 __PACKAGE__->run_before('check_auth');
61 __PACKAGE__->run_before('check_auth_for_edit',
62 except => [ qw(edit show_customer_vendor_details_dialog price_popup load_second_rows) ]);
64 __PACKAGE__->run_before('recalc',
65 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
68 __PACKAGE__->run_before('get_unalterable_data',
69 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
80 $self->order->transdate(DateTime->now_local());
81 my $extra_days = $self->type eq sales_quotation_type() ? $::instance_conf->get_reqdate_interval :
82 $self->type eq sales_order_type() ? $::instance_conf->get_delivery_date_interval : 1;
84 if ( ($self->type eq sales_order_type() && $::instance_conf->get_deliverydate_on)
85 || ($self->type eq sales_quotation_type() && $::instance_conf->get_reqdate_on)
86 && (!$self->order->reqdate)) {
87 $self->order->reqdate(DateTime->today_local->next_workday(extra_days => $extra_days));
94 title => $self->get_title_for('add'),
95 %{$self->{template_args}}
99 # edit an existing order
107 # this is to edit an order from an unsaved order object
109 # set item ids to new fake id, to identify them as new items
110 foreach my $item (@{$self->order->items_sorted}) {
111 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
113 # trigger rendering values for second row as hidden, because they
114 # are loaded only on demand. So we need to keep the values from
116 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
123 title => $self->get_title_for('edit'),
124 %{$self->{template_args}}
128 # edit a collective order (consisting of one or more existing orders)
129 sub action_edit_collective {
133 my @multi_ids = map {
134 $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1}
135 } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form;
137 # fall back to add if no ids are given
138 if (scalar @multi_ids == 0) {
143 # fall back to save as new if only one id is given
144 if (scalar @multi_ids == 1) {
145 $self->order(SL::DB::Order->new(id => $multi_ids[0])->load);
146 $self->action_save_as_new();
150 # make new order from given orders
151 my @multi_orders = map { SL::DB::Order->new(id => $_)->load } @multi_ids;
152 $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders;
153 $self->order(SL::DB::Order->new_from_multi(\@multi_orders, sort_sources_by => 'transdate'));
155 $self->action_edit();
162 my $errors = $self->delete();
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 deleted')
170 : $self->type eq purchase_order_type() ? $::locale->text('The order has been deleted')
171 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been deleted')
172 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been deleted')
174 flash_later('info', $text);
176 my @redirect_params = (
181 $self->redirect_to(@redirect_params);
188 my $errors = $self->save();
190 if (scalar @{ $errors }) {
191 $self->js->flash('error', $_) foreach @{ $errors };
192 return $self->js->render();
195 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
196 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
197 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
198 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
200 flash_later('info', $text);
203 if ($::form->{back_to_caller}) {
204 @redirect_params = $::form->{callback} ? ($::form->{callback})
205 : (controller => 'LoginScreen', action => 'user_login');
211 id => $self->order->id,
212 callback => $::form->{callback},
216 $self->redirect_to(@redirect_params);
219 # save the order as new document an open it for edit
220 sub action_save_as_new {
223 my $order = $self->order;
226 $self->js->flash('error', t8('This object has not been saved yet.'));
227 return $self->js->render();
230 # load order from db to check if values changed
231 my $saved_order = SL::DB::Order->new(id => $order->id)->load;
234 # Lets assign a new number if the user hasn't changed the previous one.
235 # If it has been changed manually then use it as-is.
236 $new_attrs{number} = (trim($order->number) eq $saved_order->number)
238 : trim($order->number);
240 # Clear transdate unless changed
241 $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
242 ? DateTime->today_local
245 # Set new reqdate unless changed if it is enabled in client config
246 if ($order->reqdate == $saved_order->reqdate) {
247 my $extra_days = $self->type eq sales_quotation_type() ? $::instance_conf->get_reqdate_interval :
248 $self->type eq sales_order_type() ? $::instance_conf->get_delivery_date_interval : 1;
250 if ( ($self->type eq sales_order_type() && !$::instance_conf->get_deliverydate_on)
251 || ($self->type eq sales_quotation_type() && !$::instance_conf->get_reqdate_on)) {
252 $new_attrs{reqdate} = '';
254 $new_attrs{reqdate} = DateTime->today_local->next_workday(extra_days => $extra_days);
257 $new_attrs{reqdate} = $order->reqdate;
261 $new_attrs{employee} = SL::DB::Manager::Employee->current;
263 # Warn on obsolete items
264 my @obsolete_positions = map { $_->position } grep { $_->part->obsolete } @{ $order->items_sorted };
265 flash_later('warning', t8('This record containts obsolete items at position #1', join ', ', @obsolete_positions)) if @obsolete_positions;
267 # Create new record from current one
268 $self->order(SL::DB::Order->new_from($order, destination_type => $order->type, attributes => \%new_attrs));
270 # no linked records on save as new
271 delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
274 $self->action_save();
279 # This is called if "print" is pressed in the print dialog.
280 # If PDF creation was requested and succeeded, the pdf is offered for download
281 # via send_file (which uses ajax in this case).
285 my $errors = $self->save();
287 if (scalar @{ $errors }) {
288 $self->js->flash('error', $_) foreach @{ $errors };
289 return $self->js->render();
292 $self->js_reset_order_and_item_ids_after_save;
294 my $redirect_url = $self->url_for(
297 id => $self->order->id,
300 my $format = $::form->{print_options}->{format};
301 my $media = $::form->{print_options}->{media};
302 my $formname = $::form->{print_options}->{formname};
303 my $copies = $::form->{print_options}->{copies};
304 my $groupitems = $::form->{print_options}->{groupitems};
305 my $printer_id = $::form->{print_options}->{printer_id};
307 # only PDF, OpenDocument & HTML for now
308 if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf html)) {
309 flash_later('error', t8('Format \'#1\' is not supported yet/anymore.', $format));
310 return $self->js->redirect_to($redirect_url)->render;
313 # only screen or printer by now
314 if (none { $media eq $_ } qw(screen printer)) {
315 flash_later('error', t8('Media \'#1\' is not supported yet/anymore.', $media));
316 return $self->js->redirect_to($redirect_url)->render;
319 # create a form for generate_attachment_filename
320 my $form = Form->new;
321 $form->{$self->nr_key()} = $self->order->number;
322 $form->{type} = $self->type;
323 $form->{format} = $format;
324 $form->{formname} = $formname;
325 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
326 my $doc_filename = $form->generate_attachment_filename();
329 my @errors = $self->generate_doc(\$doc, { media => $media,
331 formname => $formname,
332 language => $self->order->language,
333 printer_id => $printer_id,
334 groupitems => $groupitems });
335 if (scalar @errors) {
336 flash_later('error', t8('Generating the document failed: #1', $errors[0]));
337 return $self->js->redirect_to($redirect_url)->render;
340 if ($media eq 'screen') {
342 flash_later('info', t8('The document has been created.'));
345 type => SL::MIME->mime_type_from_ext($doc_filename),
346 name => $doc_filename,
350 } elsif ($media eq 'printer') {
352 my $printer_id = $::form->{print_options}->{printer_id};
353 SL::DB::Printer->new(id => $printer_id)->load->print_document(
358 flash_later('info', t8('The document has been printed.'));
361 my @warnings = $self->store_doc_to_webdav_and_filemanagement($doc, $doc_filename, $formname);
362 if (scalar @warnings) {
363 flash_later('warning', $_) for @warnings;
366 $self->save_history('PRINTED');
368 $self->js->redirect_to($redirect_url)->render;
371 sub action_preview_pdf {
374 my $errors = $self->save();
375 if (scalar @{ $errors }) {
376 $self->js->flash('error', $_) foreach @{ $errors };
377 return $self->js->render();
380 $self->js_reset_order_and_item_ids_after_save;
382 my $redirect_url = $self->url_for(
385 id => $self->order->id,
389 my $media = 'screen';
390 my $formname = $self->type;
393 # create a form for generate_attachment_filename
394 my $form = Form->new;
395 $form->{$self->nr_key()} = $self->order->number;
396 $form->{type} = $self->type;
397 $form->{format} = $format;
398 $form->{formname} = $formname;
399 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
400 my $pdf_filename = $form->generate_attachment_filename();
403 my @errors = $self->generate_doc(\$pdf, { media => $media,
405 formname => $formname,
406 language => $self->order->language,
408 if (scalar @errors) {
409 flash_later('error', t8('Conversion to PDF failed: #1', $errors[0]));
410 return $self->js->redirect_to($redirect_url)->render;
413 $self->save_history('PREVIEWED');
415 flash_later('info', t8('The PDF has been previewed'));
420 type => SL::MIME->mime_type_from_ext($pdf_filename),
421 name => $pdf_filename,
425 $self->js->redirect_to($redirect_url)->render;
428 # open the email dialog
429 sub action_save_and_show_email_dialog {
432 my $errors = $self->save();
434 if (scalar @{ $errors }) {
435 $self->js->flash('error', $_) foreach @{ $errors };
436 return $self->js->render();
439 $self->js_reset_order_and_item_ids_after_save;
441 my $cv_method = $self->cv;
443 if (!$self->order->$cv_method) {
444 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'))
449 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
450 $email_form->{to} ||= $self->order->$cv_method->email;
451 $email_form->{cc} = $self->order->$cv_method->cc;
452 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
453 # Todo: get addresses from shipto, if any
455 my $form = Form->new;
456 $form->{$self->nr_key()} = $self->order->number;
457 $form->{cusordnumber} = $self->order->cusordnumber;
458 $form->{formname} = $self->type;
459 $form->{type} = $self->type;
460 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
461 $form->{language_id} = $self->order->language->id if $self->order->language;
462 $form->{format} = 'pdf';
463 $form->{cp_id} = $self->order->contact->cp_id if $self->order->contact;
465 $email_form->{subject} = $form->generate_email_subject();
466 $email_form->{attachment_filename} = $form->generate_attachment_filename();
467 $email_form->{message} = $form->generate_email_body();
468 $email_form->{js_send_function} = 'kivi.Order.send_email()';
470 my %files = $self->get_files_for_email_dialog();
472 my @employees_with_email = grep {
473 my $user = SL::DB::Manager::AuthUser->find_by(login => $_->login);
474 $user && !!trim($user->get_config_value('email'));
475 } @{ SL::DB::Manager::Employee->get_all_sorted(query => [ deleted => 0 ]) };
478 my $all_partner_email_addresses = $self->order->customervendor->get_all_email_addresses();
480 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
481 email_form => $email_form,
482 show_bcc => $::auth->assert('email_bcc', 'may fail'),
484 is_customer => $self->cv eq 'customer',
485 ALL_EMPLOYEES => \@employees_with_email,
486 ALL_PARTNER_EMAIL_ADDRESSES => $all_partner_email_addresses,
490 ->run('kivi.Order.show_email_dialog', $dialog_html)
497 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
498 sub action_send_email {
501 my $errors = $self->save();
503 if (scalar @{ $errors }) {
504 $self->js->run('kivi.Order.close_email_dialog');
505 $self->js->flash('error', $_) foreach @{ $errors };
506 return $self->js->render();
509 $self->js_reset_order_and_item_ids_after_save;
511 my $email_form = delete $::form->{email_form};
513 if ($email_form->{additional_to}) {
514 $email_form->{to} = join ', ', grep { $_ } $email_form->{to}, @{$email_form->{additional_to}};
515 delete $email_form->{additional_to};
518 my %field_names = (to => 'email');
520 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
522 # for Form::cleanup which may be called in Form::send_email
523 $::form->{cwd} = getcwd();
524 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
526 $::form->{$_} = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
527 $::form->{media} = 'email';
529 $::form->{attachment_policy} //= '';
531 # Is an old file version available?
533 if ($::form->{attachment_policy} eq 'old_file') {
534 $attfile = SL::File->get_all(object_id => $self->order->id,
535 object_type => $self->type,
536 file_type => 'document',
537 print_variant => $::form->{formname});
540 if ($::form->{attachment_policy} ne 'no_file' && !($::form->{attachment_policy} eq 'old_file' && $attfile)) {
542 my @errors = $self->generate_doc(\$doc, {media => $::form->{media},
543 format => $::form->{print_options}->{format},
544 formname => $::form->{print_options}->{formname},
545 language => $self->order->language,
546 printer_id => $::form->{print_options}->{printer_id},
547 groupitems => $::form->{print_options}->{groupitems}});
548 if (scalar @errors) {
549 return $self->js->flash('error', t8('Generating the document failed: #1', $errors[0]))->render($self);
552 my @warnings = $self->store_doc_to_webdav_and_filemanagement($doc, $::form->{attachment_filename}, $::form->{formname});
553 if (scalar @warnings) {
554 flash_later('warning', $_) for @warnings;
557 my $sfile = SL::SessionFile::Random->new(mode => "w");
558 $sfile->fh->print($doc);
561 $::form->{tmpfile} = $sfile->file_name;
562 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
565 $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a linked record to the mail
566 $::form->send_email(\%::myconfig, $::form->{print_options}->{format});
568 # internal notes unless no email journal
569 unless ($::instance_conf->get_email_journal) {
570 my $intnotes = $self->order->intnotes;
571 $intnotes .= "\n\n" if $self->order->intnotes;
572 $intnotes .= t8('[email]') . "\n";
573 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
574 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
575 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
576 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
577 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
578 $intnotes .= t8('Message') . ": " . SL::HTML::Util->strip($::form->{message});
580 $self->order->update_attributes(intnotes => $intnotes);
583 $self->save_history('MAILED');
585 flash_later('info', t8('The email has been sent.'));
587 my @redirect_params = (
590 id => $self->order->id,
593 $self->redirect_to(@redirect_params);
596 # open the periodic invoices config dialog
598 # If there are values in the form (i.e. dialog was opened before),
599 # then use this values. Create new ones, else.
600 sub action_show_periodic_invoices_config_dialog {
603 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
604 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
605 $config ||= SL::DB::PeriodicInvoicesConfig->new(periodicity => 'm',
606 order_value_periodicity => 'p', # = same as periodicity
607 start_date_as_date => $::form->{transdate_as_date} || $::form->current_date,
608 extend_automatically_by => 12,
610 email_subject => GenericTranslations->get(
611 language_id => $::form->{language_id},
612 translation_type =>"preset_text_periodic_invoices_email_subject"),
613 email_body => GenericTranslations->get(
614 language_id => $::form->{language_id},
615 translation_type => "salutation_general")
616 . GenericTranslations->get(
617 language_id => $::form->{language_id},
618 translation_type => "salutation_punctuation_mark") . "\n\n"
619 . GenericTranslations->get(
620 language_id => $::form->{language_id},
621 translation_type =>"preset_text_periodic_invoices_email_body"),
623 # for older configs, replace email preset text if not yet set.
624 $config->email_subject(GenericTranslations->get(
625 language_id => $::form->{language_id},
626 translation_type =>"preset_text_periodic_invoices_email_subject")
627 ) unless $config->email_subject;
629 $config->email_body(GenericTranslations->get(
630 language_id => $::form->{language_id},
631 translation_type => "salutation_general")
632 . GenericTranslations->get(
633 language_id => $::form->{language_id},
634 translation_type => "salutation_punctuation_mark") . "\n\n"
635 . GenericTranslations->get(
636 language_id => $::form->{language_id},
637 translation_type =>"preset_text_periodic_invoices_email_body")
638 ) unless $config->email_body;
640 $config->periodicity('m') if none { $_ eq $config->periodicity } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
641 $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
643 $::form->get_lists(printers => "ALL_PRINTERS",
644 charts => { key => 'ALL_CHARTS',
645 transdate => 'current_date' });
647 $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
649 if ($::form->{customer_id}) {
650 $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
651 my $customer_object = SL::DB::Manager::Customer->find_by(id => $::form->{customer_id});
652 $::form->{postal_invoice} = $customer_object->postal_invoice;
653 $::form->{email_recipient_invoice_address} = $::form->{postal_invoice} ? '' : $customer_object->invoice_mail;
654 $config->send_email(0) if $::form->{postal_invoice};
657 $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
659 popup_js_close_function => 'kivi.Order.close_periodic_invoices_config_dialog()',
660 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
665 # assign the values of the periodic invoices config dialog
666 # as yaml in the hidden tag and set the status.
667 sub action_assign_periodic_invoices_config {
670 $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
672 my $config = { active => $::form->{active} ? 1 : 0,
673 terminated => $::form->{terminated} ? 1 : 0,
674 direct_debit => $::form->{direct_debit} ? 1 : 0,
675 periodicity => (any { $_ eq $::form->{periodicity} } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES) ? $::form->{periodicity} : 'm',
676 order_value_periodicity => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
677 start_date_as_date => $::form->{start_date_as_date},
678 end_date_as_date => $::form->{end_date_as_date},
679 first_billing_date_as_date => $::form->{first_billing_date_as_date},
680 print => $::form->{print} ? 1 : 0,
681 printer_id => $::form->{print} ? $::form->{printer_id} * 1 : undef,
682 copies => $::form->{copies} * 1 ? $::form->{copies} : 1,
683 extend_automatically_by => $::form->{extend_automatically_by} * 1 || undef,
684 ar_chart_id => $::form->{ar_chart_id} * 1,
685 send_email => $::form->{send_email} ? 1 : 0,
686 email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
687 email_recipient_address => $::form->{email_recipient_address},
688 email_sender => $::form->{email_sender},
689 email_subject => $::form->{email_subject},
690 email_body => $::form->{email_body},
693 my $periodic_invoices_config = SL::YAML::Dump($config);
695 my $status = $self->get_periodic_invoices_status($config);
698 ->remove('#order_periodic_invoices_config')
699 ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
700 ->run('kivi.Order.close_periodic_invoices_config_dialog')
701 ->html('#periodic_invoices_status', $status)
702 ->flash('info', t8('The periodic invoices config has been assigned.'))
706 sub action_get_has_active_periodic_invoices {
709 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
710 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
712 my $has_active_periodic_invoices =
713 $self->type eq sales_order_type()
716 && (!$config->end_date || ($config->end_date > DateTime->today_local))
717 && $config->get_previous_billed_period_start_date;
719 $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
722 # save the order and redirect to the frontend subroutine for a new
724 sub action_save_and_delivery_order {
727 $self->save_and_redirect_to(
728 controller => 'oe.pl',
729 action => 'oe_delivery_order_from_order',
733 sub action_save_and_supplier_delivery_order {
736 $self->save_and_redirect_to(
737 controller => 'controller.pl',
738 action => 'DeliveryOrder/add_from_order',
739 type => 'supplier_delivery_order',
743 # save the order and redirect to the frontend subroutine for a new
745 sub action_save_and_invoice {
748 $self->save_and_redirect_to(
749 controller => 'oe.pl',
750 action => 'oe_invoice_from_order',
754 sub action_save_and_invoice_for_advance_payment {
757 $self->save_and_redirect_to(
758 controller => 'oe.pl',
759 action => 'oe_invoice_from_order',
760 new_invoice_type => 'invoice_for_advance_payment',
764 sub action_save_and_final_invoice {
767 $self->save_and_redirect_to(
768 controller => 'oe.pl',
769 action => 'oe_invoice_from_order',
770 new_invoice_type => 'final_invoice',
774 # workflow from sales order to sales quotation
775 sub action_sales_quotation {
776 $_[0]->workflow_sales_or_request_for_quotation();
779 # workflow from sales order to sales quotation
780 sub action_request_for_quotation {
781 $_[0]->workflow_sales_or_request_for_quotation();
784 # workflow from sales quotation to sales order
785 sub action_sales_order {
786 $_[0]->workflow_sales_or_purchase_order();
789 # workflow from rfq to purchase order
790 sub action_purchase_order {
791 $_[0]->workflow_sales_or_purchase_order();
794 # workflow from purchase order to ap transaction
795 sub action_save_and_ap_transaction {
798 $self->save_and_redirect_to(
799 controller => 'ap.pl',
800 action => 'add_from_purchase_order',
804 # set form elements in respect to a changed customer or vendor
806 # This action is called on an change of the customer/vendor picker.
807 sub action_customer_vendor_changed {
810 setup_order_from_cv($self->order);
813 my $cv_method = $self->cv;
815 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
816 $self->js->show('#cp_row');
818 $self->js->hide('#cp_row');
821 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
822 $self->js->show('#shipto_selection');
824 $self->js->hide('#shipto_selection');
827 if ($cv_method eq 'customer') {
828 my $show_hide = scalar @{ $self->order->customer->additional_billing_addresses } > 0 ? 'show' : 'hide';
829 $self->js->$show_hide('#billing_address_row');
832 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
835 ->replaceWith('#order_cp_id', $self->build_contact_select)
836 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
837 ->replaceWith('#shipto_inputs ', $self->build_shipto_inputs)
838 ->replaceWith('#order_billing_address_id', $self->build_billing_address_select)
839 ->replaceWith('#business_info_row', $self->build_business_info_row)
840 ->val( '#order_taxzone_id', $self->order->taxzone_id)
841 ->val( '#order_taxincluded', $self->order->taxincluded)
842 ->val( '#order_currency_id', $self->order->currency_id)
843 ->val( '#order_payment_id', $self->order->payment_id)
844 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
845 ->val( '#order_intnotes', $self->order->intnotes)
846 ->val( '#order_language_id', $self->order->$cv_method->language_id)
847 ->focus( '#order_' . $self->cv . '_id')
848 ->run('kivi.Order.update_exchangerate');
850 $self->js_redisplay_amounts_and_taxes;
851 $self->js_redisplay_cvpartnumbers;
855 # open the dialog for customer/vendor details
856 sub action_show_customer_vendor_details_dialog {
859 my $is_customer = 'customer' eq $::form->{vc};
862 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
864 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
867 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
868 $details{discount_as_percent} = $cv->discount_as_percent;
869 $details{creditlimt} = $cv->creditlimit_as_number;
870 $details{business} = $cv->business->description if $cv->business;
871 $details{language} = $cv->language_obj->description if $cv->language_obj;
872 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
873 $details{payment_terms} = $cv->payment->description if $cv->payment;
874 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
877 foreach my $entry (@{ $cv->additional_billing_addresses }) {
878 push @{ $details{ADDITIONAL_BILLING_ADDRESSES} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
881 foreach my $entry (@{ $cv->shipto }) {
882 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
884 foreach my $entry (@{ $cv->contacts }) {
885 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
888 $_[0]->render('common/show_vc_details', { layout => 0 },
889 is_customer => $is_customer,
894 # called if a unit in an existing item row is changed
895 sub action_unit_changed {
898 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
899 my $item = $self->order->items_sorted->[$idx];
901 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
902 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
907 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
908 $self->js_redisplay_line_values;
909 $self->js_redisplay_amounts_and_taxes;
913 # update item input row when a part ist picked
914 sub action_update_item_input_row {
917 delete $::form->{add_item}->{$_} for qw(create_part_type sellprice_as_number discount_as_percent);
919 my $form_attr = $::form->{add_item};
921 return unless $form_attr->{parts_id};
923 my $record = $self->order;
924 my $item = SL::DB::OrderItem->new(%$form_attr);
925 $item->unit($item->part->unit);
927 my ($price_src, $discount_src) = get_best_price_and_discount_source($record, $item, 0);
930 ->val ('#add_item_unit', $item->unit)
931 ->val ('#add_item_description', $item->part->description)
932 ->val ('#add_item_sellprice_as_number', '')
933 ->attr ('#add_item_sellprice_as_number', 'placeholder', $price_src->price_as_number)
934 ->attr ('#add_item_sellprice_as_number', 'title', $price_src->source_description)
935 ->val ('#add_item_discount_as_percent', '')
936 ->attr ('#add_item_discount_as_percent', 'placeholder', $discount_src->discount_as_percent)
937 ->attr ('#add_item_discount_as_percent', 'title', $discount_src->source_description)
941 # add an item row for a new item entered in the input row
942 sub action_add_item {
945 delete $::form->{add_item}->{create_part_type};
947 my $form_attr = $::form->{add_item};
949 return unless $form_attr->{parts_id};
951 my $item = new_item($self->order, $form_attr);
953 $self->order->add_items($item);
957 $self->get_item_cvpartnumber($item);
959 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
960 my $row_as_html = $self->p->render('order/tabs/_row',
966 if ($::form->{insert_before_item_id}) {
968 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
971 ->append('#row_table_id', $row_as_html);
974 if ( $item->part->is_assortment ) {
975 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
976 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
977 my $attr = { parts_id => $assortment_item->parts_id,
978 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
979 unit => $assortment_item->unit,
980 description => $assortment_item->part->description,
982 my $item = new_item($self->order, $attr);
984 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
985 $item->discount(1) unless $assortment_item->charge;
987 $self->order->add_items( $item );
989 $self->get_item_cvpartnumber($item);
990 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
991 my $row_as_html = $self->p->render('order/tabs/_row',
996 if ($::form->{insert_before_item_id}) {
998 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
1001 ->append('#row_table_id', $row_as_html);
1007 ->val('.add_item_input', '')
1008 ->attr('.add_item_input', 'placeholder', '')
1009 ->attr('.add_item_input', 'title', '')
1010 ->run('kivi.Order.init_row_handlers')
1011 ->run('kivi.Order.renumber_positions')
1012 ->focus('#add_item_parts_id_name');
1014 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
1016 $self->js_redisplay_amounts_and_taxes;
1017 $self->js->render();
1020 # add item rows for multiple items at once
1021 sub action_add_multi_items {
1024 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
1025 return $self->js->render() unless scalar @form_attr;
1028 foreach my $attr (@form_attr) {
1029 my $item = new_item($self->order, $attr);
1031 if ( $item->part->is_assortment ) {
1032 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
1033 my $attr = { parts_id => $assortment_item->parts_id,
1034 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
1035 unit => $assortment_item->unit,
1036 description => $assortment_item->part->description,
1038 my $item = new_item($self->order, $attr);
1040 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
1041 $item->discount(1) unless $assortment_item->charge;
1046 $self->order->add_items(@items);
1050 foreach my $item (@items) {
1051 $self->get_item_cvpartnumber($item);
1052 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1053 my $row_as_html = $self->p->render('order/tabs/_row',
1059 if ($::form->{insert_before_item_id}) {
1061 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
1064 ->append('#row_table_id', $row_as_html);
1069 ->run('kivi.Part.close_picker_dialogs')
1070 ->run('kivi.Order.init_row_handlers')
1071 ->run('kivi.Order.renumber_positions')
1072 ->focus('#add_item_parts_id_name');
1074 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
1076 $self->js_redisplay_amounts_and_taxes;
1077 $self->js->render();
1080 # recalculate all linetotals, amounts and taxes and redisplay them
1081 sub action_recalc_amounts_and_taxes {
1086 $self->js_redisplay_line_values;
1087 $self->js_redisplay_amounts_and_taxes;
1088 $self->js->render();
1091 sub action_update_exchangerate {
1095 is_standard => $self->order->currency_id == $::instance_conf->get_currency_id,
1096 currency_name => $self->order->currency->name,
1097 exchangerate => $self->order->daily_exchangerate_as_null_number,
1100 $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
1103 # redisplay item rows if they are sorted by an attribute
1104 sub action_reorder_items {
1108 partnumber => sub { $_[0]->part->partnumber },
1109 description => sub { $_[0]->description },
1110 qty => sub { $_[0]->qty },
1111 sellprice => sub { $_[0]->sellprice },
1112 discount => sub { $_[0]->discount },
1113 cvpartnumber => sub { $_[0]->{cvpartnumber} },
1116 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1118 my $method = $sort_keys{$::form->{order_by}};
1119 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
1120 if ($::form->{sort_dir}) {
1121 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
1122 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
1124 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
1127 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
1128 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
1130 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
1134 ->run('kivi.Order.redisplay_items', \@to_sort)
1138 # show the popup to choose a price/discount source
1139 sub action_price_popup {
1142 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
1143 my $item = $self->order->items_sorted->[$idx];
1145 $self->render_price_dialog($item);
1148 # save the order in a session variable and redirect to the part controller
1149 sub action_create_part {
1152 my $previousform = $::auth->save_form_in_session(non_scalars => 1);
1154 my $callback = $self->url_for(
1155 action => 'return_from_create_part',
1156 type => $self->type, # type is needed for check_auth on return
1157 previousform => $previousform,
1160 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.'));
1162 my @redirect_params = (
1163 controller => 'Part',
1165 part_type => $::form->{add_item}->{create_part_type},
1166 callback => $callback,
1170 $self->redirect_to(@redirect_params);
1173 sub action_return_from_create_part {
1176 $self->{created_part} = SL::DB::Part->new(id => delete $::form->{new_parts_id})->load if $::form->{new_parts_id};
1178 $::auth->restore_form_from_session(delete $::form->{previousform});
1180 # set item ids to new fake id, to identify them as new items
1181 foreach my $item (@{$self->order->items_sorted}) {
1182 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1186 $self->get_unalterable_data();
1187 $self->pre_render();
1189 # trigger rendering values for second row/longdescription as hidden,
1190 # because they are loaded only on demand. So we need to keep the values
1192 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1193 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
1197 title => $self->get_title_for('edit'),
1198 %{$self->{template_args}}
1203 # load the second row for one or more items
1205 # This action gets the html code for all items second rows by rendering a template for
1206 # the second row and sets the html code via client js.
1207 sub action_load_second_rows {
1210 $self->recalc() if $self->order->is_sales; # for margin calculation
1212 foreach my $item_id (@{ $::form->{item_ids} }) {
1213 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1214 my $item = $self->order->items_sorted->[$idx];
1216 $self->js_load_second_row($item, $item_id, 0);
1219 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
1221 $self->js->render();
1224 # update description, notes and sellprice from master data
1225 sub action_update_row_from_master_data {
1228 foreach my $item_id (@{ $::form->{item_ids} }) {
1229 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1230 my $item = $self->order->items_sorted->[$idx];
1231 my $texts = get_part_texts($item->part, $self->order->language_id);
1233 $item->description($texts->{description});
1234 $item->longdescription($texts->{longdescription});
1236 my ($price_src, $discount_src) = get_best_price_and_discount_source($self->order, $item, 1);
1238 $item->sellprice($price_src->price);
1239 $item->active_price_source($price_src);
1240 $item->discount($discount_src->discount);
1241 $item->active_discount_source($discount_src);
1243 my $price_editable = $self->order->is_sales ? $::auth->assert('sales_edit_prices', 1) : $::auth->assert('purchase_edit_prices', 1);
1246 ->run('kivi.Order.set_price_and_source_text', $item_id, $price_src ->source, $price_src ->source_description, $item->sellprice_as_number, $price_editable)
1247 ->run('kivi.Order.set_discount_and_source_text', $item_id, $discount_src->source, $discount_src->source_description, $item->discount_as_percent, $price_editable)
1248 ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
1249 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
1250 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
1252 if ($self->search_cvpartnumber) {
1253 $self->get_item_cvpartnumber($item);
1254 $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
1259 $self->js_redisplay_line_values;
1260 $self->js_redisplay_amounts_and_taxes;
1262 $self->js->render();
1265 sub action_save_phone_note {
1268 if (!$::form->{phone_note}->{subject} || !$::form->{phone_note}->{body}) {
1269 return $self->js->flash('error', t8('Phone note needs a subject and a body.'))->render;
1273 if ($::form->{phone_note}->{id}) {
1274 $phone_note = first { $_->id == $::form->{phone_note}->{id} } @{$self->order->phone_notes};
1275 return $self->js->flash('error', t8('Phone note not found for this order.'))->render if !$phone_note;
1278 $phone_note = SL::DB::Note->new() if !$phone_note;
1279 my $is_new = !$phone_note->id;
1281 $phone_note->assign_attributes(%{ $::form->{phone_note} },
1282 trans_id => $self->order->id,
1283 trans_module => 'oe',
1284 employee => SL::DB::Manager::Employee->current);
1287 $self->order(SL::DB::Order->new(id => $self->order->id)->load);
1289 my $tab_as_html = $self->p->render('order/tabs/phone_notes', SELF => $self);
1292 ->replaceWith('#phone-notes', $tab_as_html)
1293 ->html('#num_phone_notes', (scalar @{$self->order->phone_notes}) ? ' (' . scalar @{$self->order->phone_notes} . ')' : '')
1294 ->flash('info', $is_new ? t8('Phone note has been created.') : t8('Phone note has been updated.'))
1298 sub action_delete_phone_note {
1301 my $phone_note = first { $_->id == $::form->{phone_note}->{id} } @{$self->order->phone_notes};
1303 return $self->js->flash('error', t8('Phone note not found for this order.'))->render if !$phone_note;
1305 $phone_note->delete;
1306 $self->order(SL::DB::Order->new(id => $self->order->id)->load);
1308 my $tab_as_html = $self->p->render('order/tabs/phone_notes', SELF => $self);
1311 ->replaceWith('#phone-notes', $tab_as_html)
1312 ->html('#num_phone_notes', (scalar @{$self->order->phone_notes}) ? ' (' . scalar @{$self->order->phone_notes} . ')' : '')
1313 ->flash('info', t8('Phone note has been deleted.'))
1317 sub js_load_second_row {
1318 my ($self, $item, $item_id, $do_parse) = @_;
1321 # Parse values from form (they are formated while rendering (template)).
1322 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1323 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1324 foreach my $var (@{ $item->cvars_by_config }) {
1325 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1327 $item->parse_custom_variable_values;
1330 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1333 ->html('#second_row_' . $item_id, $row_as_html)
1334 ->data('#second_row_' . $item_id, 'loaded', 1);
1337 sub js_redisplay_line_values {
1340 my $is_sales = $self->order->is_sales;
1342 # sales orders with margins
1347 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1348 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1349 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1350 ]} @{ $self->order->items_sorted };
1354 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1355 ]} @{ $self->order->items_sorted };
1359 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1362 sub js_redisplay_amounts_and_taxes {
1365 if (scalar @{ $self->{taxes} }) {
1366 $self->js->show('#taxincluded_row_id');
1368 $self->js->hide('#taxincluded_row_id');
1371 if ($self->order->taxincluded) {
1372 $self->js->hide('#subtotal_row_id');
1374 $self->js->show('#subtotal_row_id');
1377 if ($self->order->is_sales) {
1378 my $is_neg = $self->order->marge_total < 0;
1380 ->html('#marge_total_id', $::form->format_amount(\%::myconfig, $self->order->marge_total, 2))
1381 ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
1382 ->action_if( $is_neg, 'addClass', '#marge_total_id', 'plus0')
1383 ->action_if( $is_neg, 'addClass', '#marge_percent_id', 'plus0')
1384 ->action_if( $is_neg, 'addClass', '#marge_percent_sign_id', 'plus0')
1385 ->action_if(!$is_neg, 'removeClass', '#marge_total_id', 'plus0')
1386 ->action_if(!$is_neg, 'removeClass', '#marge_percent_id', 'plus0')
1387 ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
1391 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
1392 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
1393 ->remove('.tax_row')
1394 ->insertBefore($self->build_tax_rows, '#amount_row_id');
1397 sub js_redisplay_cvpartnumbers {
1400 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1402 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1405 ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
1408 sub js_reset_order_and_item_ids_after_save {
1412 ->val('#id', $self->order->id)
1413 ->val('#converted_from_oe_id', '')
1414 ->val('#order_' . $self->nr_key(), $self->order->number);
1417 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1418 next if !$self->order->items_sorted->[$idx]->id;
1419 next if $form_item_id !~ m{^new};
1421 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1422 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1423 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1427 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1434 sub init_valid_types {
1435 [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
1441 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1442 die "Not a valid type for order";
1445 $self->type($::form->{type});
1451 my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer'
1452 : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
1453 : die "Not a valid type for order";
1458 sub init_search_cvpartnumber {
1461 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1462 my $search_cvpartnumber;
1463 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1464 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1466 return $search_cvpartnumber;
1469 sub init_show_update_button {
1472 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1483 sub init_all_price_factors {
1484 SL::DB::Manager::PriceFactor->get_all;
1487 sub init_part_picker_classification_ids {
1489 my $attribute = 'used_for_' . ($self->type =~ m{sales} ? 'sale' : 'purchase');
1491 return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => [ $attribute => 1 ]) } ];
1497 my $right_for = { map { $_ => $_.'_edit' . ' | ' . $_.'_view' } @{$self->valid_types} };
1499 my $right = $right_for->{ $self->type };
1500 $right ||= 'DOES_NOT_EXIST';
1502 $::auth->assert($right);
1505 sub check_auth_for_edit {
1508 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
1510 my $right = $right_for->{ $self->type };
1511 $right ||= 'DOES_NOT_EXIST';
1513 $::auth->assert($right);
1516 # build the selection box for contacts
1518 # Needed, if customer/vendor changed.
1519 sub build_contact_select {
1522 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1523 value_key => 'cp_id',
1524 title_key => 'full_name_dep',
1525 default => $self->order->cp_id,
1527 style => 'width: 300px',
1531 # build the selection box for the additional billing address
1533 # Needed, if customer/vendor changed.
1534 sub build_billing_address_select {
1537 return '' if $self->cv ne 'customer';
1539 select_tag('order.billing_address_id',
1540 [ {displayable_id => '', id => ''}, $self->order->{$self->cv}->additional_billing_addresses ],
1542 title_key => 'displayable_id',
1543 default => $self->order->billing_address_id,
1545 style => 'width: 300px',
1549 # build the selection box for shiptos
1551 # Needed, if customer/vendor changed.
1552 sub build_shipto_select {
1555 select_tag('order.shipto_id',
1556 [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
1557 value_key => 'shipto_id',
1558 title_key => 'displayable_id',
1559 default => $self->order->shipto_id,
1561 style => 'width: 300px',
1565 # build the inputs for the cusom shipto dialog
1567 # Needed, if customer/vendor changed.
1568 sub build_shipto_inputs {
1571 my $content = $self->p->render('common/_ship_to_dialog',
1572 vc_obj => $self->order->customervendor,
1573 cs_obj => $self->order->custom_shipto,
1574 cvars => $self->order->custom_shipto->cvars_by_config,
1575 id_selector => '#order_shipto_id');
1577 div_tag($content, id => 'shipto_inputs');
1580 # render the info line for business
1582 # Needed, if customer/vendor changed.
1583 sub build_business_info_row
1585 $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
1588 # build the rows for displaying taxes
1590 # Called if amounts where recalculated and redisplayed.
1591 sub build_tax_rows {
1595 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
1596 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
1598 return $rows_as_html;
1602 sub render_price_dialog {
1603 my ($self, $record_item) = @_;
1605 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1609 'kivi.io.price_chooser_dialog',
1610 t8('Available Prices'),
1611 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1616 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1617 # $self->js->show('#dialog_flash_error');
1626 return if !$::form->{id};
1628 $self->order(SL::DB::Order->new(id => $::form->{id})->load);
1630 # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
1631 # You need a custom shipto object to call cvars_by_config to get the cvars.
1632 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
1634 return $self->order;
1637 # load or create a new order object
1639 # And assign changes from the form to this object.
1640 # If the order is loaded from db, check if items are deleted in the form,
1641 # remove them form the object and collect them for removing from db on saving.
1642 # Then create/update items from form (via make_item) and add them.
1646 # add_items adds items to an order with no items for saving, but they cannot
1647 # be retrieved via items until the order is saved. Adding empty items to new
1648 # order here solves this problem.
1650 $order = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1651 $order ||= SL::DB::Order->new(orderitems => [],
1652 quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())),
1653 currency_id => $::instance_conf->get_currency_id(),);
1655 my $cv_id_method = $self->cv . '_id';
1656 if (!$::form->{id} && $::form->{$cv_id_method}) {
1657 $order->$cv_id_method($::form->{$cv_id_method});
1658 setup_order_from_cv($order);
1661 my $form_orderitems = delete $::form->{order}->{orderitems};
1662 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1664 $order->assign_attributes(%{$::form->{order}});
1666 $self->setup_custom_shipto_from_form($order, $::form);
1668 if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) {
1669 my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
1670 $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
1673 # remove deleted items
1674 $self->item_ids_to_delete([]);
1675 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1676 my $item = $order->orderitems->[$idx];
1677 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1678 splice @{$order->orderitems}, $idx, 1;
1679 push @{$self->item_ids_to_delete}, $item->id;
1685 foreach my $form_attr (@{$form_orderitems}) {
1686 my $item = make_item($order, $form_attr);
1687 $item->position($pos);
1691 $order->add_items(grep {!$_->id} @items);
1696 # create or update items from form
1698 # Make item objects from form values. For items already existing read from db.
1699 # Create a new item else. And assign attributes.
1701 my ($record, $attr) = @_;
1704 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1706 my $is_new = !$item;
1708 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1709 # they cannot be retrieved via custom_variables until the order/orderitem is
1710 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1711 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1713 $item->assign_attributes(%$attr);
1716 my $texts = get_part_texts($item->part, $record->language_id);
1717 $item->longdescription($texts->{longdescription}) if !defined $attr->{longdescription};
1718 $item->project_id($record->globalproject_id) if !defined $attr->{project_id};
1719 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number};
1727 # This is used to add one item
1729 my ($record, $attr) = @_;
1731 my $item = SL::DB::OrderItem->new;
1733 # Remove attributes where the user left or set the inputs empty.
1734 # So these attributes will be undefined and we can distinguish them
1735 # from zero later on.
1736 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1737 delete $attr->{$_} if $attr->{$_} eq '';
1740 $item->assign_attributes(%$attr);
1741 $item->qty(1.0) if !$item->qty;
1742 $item->unit($item->part->unit) if !$item->unit;
1744 my ($price_src, $discount_src) = get_best_price_and_discount_source($record, $item, 0);
1747 $new_attr{description} = $item->part->description if ! $item->description;
1748 $new_attr{qty} = 1.0 if ! $item->qty;
1749 $new_attr{price_factor_id} = $item->part->price_factor_id if ! $item->price_factor_id;
1750 $new_attr{sellprice} = $price_src->price;
1751 $new_attr{discount} = $discount_src->discount;
1752 $new_attr{active_price_source} = $price_src;
1753 $new_attr{active_discount_source} = $discount_src;
1754 $new_attr{longdescription} = $item->part->notes if ! defined $attr->{longdescription};
1755 $new_attr{project_id} = $record->globalproject_id;
1756 $new_attr{lastcost} = $record->is_sales ? $item->part->lastcost : 0;
1758 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1759 # they cannot be retrieved via custom_variables until the order/orderitem is
1760 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1761 $new_attr{custom_variables} = [];
1763 my $texts = get_part_texts($item->part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription});
1765 $item->assign_attributes(%new_attr, %{ $texts });
1770 sub setup_order_from_cv {
1773 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id language_id));
1775 $order->intnotes($order->customervendor->notes);
1777 return if !$order->is_sales;
1779 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1780 $order->taxincluded(defined($order->customer->taxincluded_checked)
1781 ? $order->customer->taxincluded_checked
1782 : $::myconfig{taxincluded_checked});
1784 my $address = $order->customer->default_billing_address;;
1785 $order->billing_address_id($address ? $address->id : undef);
1788 # setup custom shipto from form
1790 # The dialog returns form variables starting with 'shipto' and cvars starting
1791 # with 'shiptocvar_'.
1792 # Mark it to be deleted if a shipto from master data is selected
1793 # (i.e. order has a shipto).
1794 # Else, update or create a new custom shipto. If the fields are empty, it
1795 # will not be saved on save.
1796 sub setup_custom_shipto_from_form {
1797 my ($self, $order, $form) = @_;
1799 if ($order->shipto) {
1800 $self->is_custom_shipto_to_delete(1);
1802 my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1804 my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
1805 my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
1807 $custom_shipto->assign_attributes(%$shipto_attrs);
1808 $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
1812 # recalculate prices and taxes
1814 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1818 my %pat = $self->order->calculate_prices_and_taxes();
1820 $self->{taxes} = [];
1821 foreach my $tax_id (keys %{ $pat{taxes_by_tax_id} }) {
1822 my $netamount = sum0 map { $pat{amounts}->{$_}->{amount} } grep { $pat{amounts}->{$_}->{tax_id} == $tax_id } keys %{ $pat{amounts} };
1824 push(@{ $self->{taxes} }, { amount => $pat{taxes_by_tax_id}->{$tax_id},
1825 netamount => $netamount,
1826 tax => SL::DB::Tax->new(id => $tax_id)->load });
1828 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items_sorted}, @{$pat{items}};
1831 # get data for saving, printing, ..., that is not changed in the form
1833 # Only cvars for now.
1834 sub get_unalterable_data {
1837 foreach my $item (@{ $self->order->items }) {
1838 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1839 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1840 foreach my $var (@{ $item->cvars_by_config }) {
1841 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1843 $item->parse_custom_variable_values;
1849 # And remove related files in the spool directory
1854 my $db = $self->order->db;
1856 $db->with_transaction(
1858 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1859 $self->order->delete;
1860 my $spool = $::lx_office_conf{paths}->{spool};
1861 unlink map { "$spool/$_" } @spoolfiles if $spool;
1863 $self->save_history('DELETED');
1866 }) || push(@{$errors}, $db->error);
1873 # And delete items that are deleted in the form.
1878 my $db = $self->order->db;
1880 # check for new or updated phone note
1881 if ($::form->{phone_note}->{subject} || $::form->{phone_note}->{body}) {
1882 if (!$::form->{phone_note}->{subject} || !$::form->{phone_note}->{body}) {
1883 return [t8('Phone note needs a subject and a body.')];
1887 if ($::form->{phone_note}->{id}) {
1888 $phone_note = first { $_->id == $::form->{phone_note}->{id} } @{$self->order->phone_notes};
1889 return [t8('Phone note not found for this order.')] if !$phone_note;
1892 $phone_note = SL::DB::Note->new() if !$phone_note;
1893 my $is_new = !$phone_note->id;
1895 $phone_note->assign_attributes(%{ $::form->{phone_note} },
1896 trans_id => $self->order->id,
1897 trans_module => 'oe',
1898 employee => SL::DB::Manager::Employee->current);
1900 $self->order->add_phone_notes($phone_note) if $is_new;
1903 $db->with_transaction(sub {
1904 # delete custom shipto if it is to be deleted or if it is empty
1905 if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
1906 $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
1907 $self->order->custom_shipto(undef);
1910 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1911 $self->order->save(cascade => 1);
1914 if ($::form->{converted_from_oe_id}) {
1915 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1917 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1918 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1919 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
1920 $src->link_to_record($self->order);
1922 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1924 foreach (@{ $self->order->items_sorted }) {
1925 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1927 SL::DB::RecordLink->new(from_table => 'orderitems',
1928 from_id => $from_id,
1929 to_table => 'orderitems',
1936 $self->link_requirement_specs_linking_to_created_from_objects(@converted_from_oe_ids);
1939 $self->set_project_in_linked_requirement_specs if $self->order->globalproject_id;
1941 $self->save_history('SAVED');
1944 }) || push(@{$errors}, $db->error);
1949 sub workflow_sales_or_request_for_quotation {
1953 my $errors = $self->save();
1955 if (scalar @{ $errors }) {
1956 $self->js->flash('error', $_) for @{ $errors };
1957 return $self->js->render();
1960 my $destination_type = $::form->{type} eq sales_order_type() ? sales_quotation_type() : request_quotation_type();
1962 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1963 delete $::form->{id};
1965 # no linked records from order to quotations
1966 delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
1968 # set item ids to new fake id, to identify them as new items
1969 foreach my $item (@{$self->order->items_sorted}) {
1970 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1974 $::form->{type} = $destination_type;
1975 $self->type($self->init_type);
1976 $self->cv ($self->init_cv);
1980 $self->get_unalterable_data();
1981 $self->pre_render();
1983 # trigger rendering values for second row as hidden, because they
1984 # are loaded only on demand. So we need to keep the values from the
1986 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1990 title => $self->get_title_for('edit'),
1991 %{$self->{template_args}}
1995 sub workflow_sales_or_purchase_order {
1999 my $errors = $self->save();
2001 if (scalar @{ $errors }) {
2002 $self->js->flash('error', $_) foreach @{ $errors };
2003 return $self->js->render();
2006 my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type()
2007 : $::form->{type} eq request_quotation_type() ? purchase_order_type()
2008 : $::form->{type} eq purchase_order_type() ? sales_order_type()
2009 : $::form->{type} eq sales_order_type() ? purchase_order_type()
2012 # check for direct delivery
2013 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
2015 if ( $::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()
2016 && $::form->{use_shipto} && $self->order->shipto) {
2017 $custom_shipto = $self->order->shipto->clone('SL::DB::Order');
2020 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
2021 $self->{converted_from_oe_id} = delete $::form->{id};
2023 # set item ids to new fake id, to identify them as new items
2024 foreach my $item (@{$self->order->items_sorted}) {
2025 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
2028 if ($::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()) {
2029 if ($::form->{use_shipto}) {
2030 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
2032 # remove any custom shipto if not wanted
2033 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
2038 $::form->{type} = $destination_type;
2039 $self->type($self->init_type);
2040 $self->cv ($self->init_cv);
2044 $self->get_unalterable_data();
2045 $self->pre_render();
2047 # trigger rendering values for second row as hidden, because they
2048 # are loaded only on demand. So we need to keep the values from the
2050 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
2054 title => $self->get_title_for('edit'),
2055 %{$self->{template_args}}
2063 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
2064 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
2065 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
2066 $self->{all_languages} = SL::DB::Manager::Language->get_all_sorted( query => [ or => [ obsolete => 0, id => $self->order->language_id ] ] );
2067 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
2070 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
2073 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
2075 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
2076 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
2077 $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
2078 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
2079 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
2081 my $print_form = Form->new('');
2082 $print_form->{type} = $self->type;
2083 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
2084 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
2085 form => $print_form,
2086 options => {dialog_name_prefix => 'print_options.',
2090 no_opendocument => 0,
2094 foreach my $item (@{$self->order->orderitems}) {
2095 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
2096 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
2097 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
2100 if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
2101 # Calculate shipped qtys here to prevent calling calculate for every item via the items method.
2102 # Do not use write_to_objects to prevent order->delivered to be set, because this should be
2103 # the value from db, which can be set manually or is set when linked delivery orders are saved.
2104 SL::Helper::ShippedQty->new->calculate($self->order)->write_to(\@{$self->order->items});
2107 if ($self->order->number && $::instance_conf->get_webdav) {
2108 my $webdav = SL::Webdav->new(
2109 type => $self->type,
2110 number => $self->order->number,
2112 my @all_objects = $webdav->get_all_objects;
2113 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
2115 link => File::Spec->catfile($_->full_filedescriptor),
2119 if ( (any { $self->type eq $_ } (sales_quotation_type(), sales_order_type()))
2120 && $::instance_conf->get_transport_cost_reminder_article_number_id ) {
2121 $self->{template_args}->{transport_cost_reminder_article} = SL::DB::Part->new(id => $::instance_conf->get_transport_cost_reminder_article_number_id)->load;
2123 $self->{template_args}->{longdescription_dialog_size_percentage} = SL::Helper::UserPreferences::DisplayPreferences->new()->get_longdescription_dialog_size_percentage();
2125 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
2127 $self->{template_args}->{num_phone_notes} = scalar @{ $self->order->phone_notes || [] };
2129 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.Validator kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
2130 edit_periodic_invoices_config calculate_qty follow_up show_history);
2131 $self->setup_edit_action_bar;
2134 sub setup_edit_action_bar {
2135 my ($self, %params) = @_;
2137 my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
2138 || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
2139 || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
2141 my @req_trans_cost_art = qw(kivi.Order.check_transport_cost_article_presence) x!!$::instance_conf->get_transport_cost_reminder_article_number_id;
2142 my @req_cusordnumber = qw(kivi.Order.check_cusordnumber_presence) x($self->type eq sales_order_type() && $::instance_conf->get_order_warn_no_cusordnumber);
2144 my $has_invoice_for_advance_payment;
2145 if ($self->order->id && $self->type eq sales_order_type()) {
2146 my $lr = $self->order->linked_records(direction => 'to', to => ['Invoice']);
2147 $has_invoice_for_advance_payment = any {'SL::DB::Invoice' eq ref $_ && "invoice_for_advance_payment" eq $_->type} @$lr;
2150 my $has_final_invoice;
2151 if ($self->order->id && $self->type eq sales_order_type()) {
2152 my $lr = $self->order->linked_records(direction => 'to', to => ['Invoice']);
2153 $has_final_invoice = any {'SL::DB::Invoice' eq ref $_ && "final_invoice" eq $_->type} @$lr;
2156 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
2157 my $right = $right_for->{ $self->type };
2158 $right ||= 'DOES_NOT_EXIST';
2159 my $may_edit_create = $::auth->assert($right, 'may fail');
2161 for my $bar ($::request->layout->get('actionbar')) {
2166 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
2167 $::instance_conf->get_order_warn_no_deliverydate,
2169 checks => [ 'kivi.Order.check_save_active_periodic_invoices', ['kivi.validate_form','#order_form'],
2170 @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 Close'),
2176 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
2177 $::instance_conf->get_order_warn_no_deliverydate,
2180 checks => [ 'kivi.Order.check_save_active_periodic_invoices', ['kivi.validate_form','#order_form'],
2181 @req_trans_cost_art, @req_cusordnumber,
2183 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2187 call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
2188 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2189 @req_trans_cost_art, @req_cusordnumber,
2191 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
2192 : !$self->order->id ? t8('This object has not been saved yet.')
2195 ], # end of combobox "Save"
2202 t8('Save and Quotation'),
2203 submit => [ '#order_form', { action => "Order/sales_quotation" } ],
2204 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2205 only_if => (any { $self->type eq $_ } (sales_order_type())),
2206 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2210 submit => [ '#order_form', { action => "Order/request_for_quotation" } ],
2211 only_if => (any { $self->type eq $_ } (purchase_order_type())),
2212 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2215 t8('Save and Sales Order'),
2216 submit => [ '#order_form', { action => "Order/sales_order" } ],
2217 checks => [ @req_trans_cost_art ],
2218 only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
2219 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2222 t8('Save and Purchase Order'),
2223 call => [ 'kivi.Order.purchase_order_check_for_direct_delivery' ],
2224 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2225 only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
2226 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2229 t8('Save and Delivery Order'),
2230 call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
2231 $::instance_conf->get_order_warn_no_deliverydate,
2233 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2234 @req_trans_cost_art, @req_cusordnumber,
2236 only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())),
2237 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2240 t8('Save and Supplier Delivery Order'),
2241 call => [ 'kivi.Order.save', 'save_and_supplier_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
2242 $::instance_conf->get_order_warn_no_deliverydate,
2244 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2245 @req_trans_cost_art, @req_cusordnumber,
2247 only_if => (any { $self->type eq $_ } (purchase_order_type())),
2248 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2251 t8('Save and Invoice'),
2252 call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
2253 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2254 @req_trans_cost_art, @req_cusordnumber,
2256 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2259 ($has_invoice_for_advance_payment ? t8('Save and Further Invoice for Advance Payment') : t8('Save and Invoice for Advance Payment')),
2260 call => [ 'kivi.Order.save', 'save_and_invoice_for_advance_payment', $::instance_conf->get_order_warn_duplicate_parts ],
2261 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2262 @req_trans_cost_art, @req_cusordnumber,
2264 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
2265 : $has_final_invoice ? t8('This order has already a final invoice.')
2267 only_if => (any { $self->type eq $_ } (sales_order_type())),
2270 t8('Save and Final Invoice'),
2271 call => [ 'kivi.Order.save', 'save_and_final_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
2272 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2273 @req_trans_cost_art, @req_cusordnumber,
2275 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
2276 : $has_final_invoice ? t8('This order has already a final invoice.')
2278 only_if => (any { $self->type eq $_ } (sales_order_type())) && $has_invoice_for_advance_payment,
2281 t8('Save and AP Transaction'),
2282 call => [ 'kivi.Order.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
2283 only_if => (any { $self->type eq $_ } (purchase_order_type())),
2284 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2287 ], # end of combobox "Workflow"
2294 t8('Save and preview PDF'),
2295 call => [ 'kivi.Order.save', 'preview_pdf', $::instance_conf->get_order_warn_duplicate_parts,
2296 $::instance_conf->get_order_warn_no_deliverydate,
2298 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2299 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2302 t8('Save and print'),
2303 call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts,
2304 $::instance_conf->get_order_warn_no_deliverydate,
2306 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2307 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2310 t8('Save and E-mail'),
2311 id => 'save_and_email_action',
2312 call => [ 'kivi.Order.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts,
2313 $::instance_conf->get_order_warn_no_deliverydate,
2315 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
2316 : !$self->order->id ? t8('This object has not been saved yet.')
2320 t8('Download attachments of all parts'),
2321 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
2322 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
2323 only_if => $::instance_conf->get_doc_storage,
2325 ], # end of combobox "Export"
2329 call => [ 'kivi.Order.delete_order' ],
2330 confirm => $::locale->text('Do you really want to delete this object?'),
2331 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
2332 : !$self->order->id ? t8('This object has not been saved yet.')
2334 only_if => $deletion_allowed,
2343 call => [ 'set_history_window', $self->order->id, 'id' ],
2344 disabled => !$self->order->id ? t8('This record has not been saved yet.') : undef,
2348 call => [ 'kivi.Order.follow_up_window' ],
2349 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
2350 only_if => $::auth->assert('productivity', 1),
2352 ], # end of combobox "more"
2358 my ($self, $doc_ref, $params) = @_;
2360 my $order = $self->order;
2363 my $print_form = Form->new('');
2364 $print_form->{type} = $order->type;
2365 $print_form->{formname} = $params->{formname} || $order->type;
2366 $print_form->{format} = $params->{format} || 'pdf';
2367 $print_form->{media} = $params->{media} || 'file';
2368 $print_form->{groupitems} = $params->{groupitems};
2369 $print_form->{printer_id} = $params->{printer_id};
2370 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
2372 $order->language($params->{language});
2373 $order->flatten_to_form($print_form, format_amounts => 1);
2377 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
2378 $template_ext = 'odt';
2379 $template_type = 'OpenDocument';
2380 } elsif ($print_form->{format} =~ m{html}i) {
2381 $template_ext = 'html';
2382 $template_type = 'HTML';
2385 # search for the template
2386 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
2387 name => $print_form->{formname},
2388 extension => $template_ext,
2389 email => $print_form->{media} eq 'email',
2390 language => $params->{language},
2391 printer_id => $print_form->{printer_id},
2394 if (!defined $template_file) {
2395 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);
2398 return @errors if scalar @errors;
2400 $print_form->throw_on_error(sub {
2402 $print_form->prepare_for_printing;
2404 $$doc_ref = SL::Helper::CreatePDF->create_pdf(
2405 format => $print_form->{format},
2406 template_type => $template_type,
2407 template => $template_file,
2408 variables => $print_form,
2409 variable_content_types => {
2410 longdescription => 'html',
2411 partnotes => 'html',
2413 $::form->get_variable_content_types_for_cvars,
2417 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
2423 sub get_files_for_email_dialog {
2426 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
2428 return %files if !$::instance_conf->get_doc_storage;
2430 if ($self->order->id) {
2431 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
2432 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
2433 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
2434 $files{project_files} = [ SL::File->get_all( object_id => $self->order->globalproject_id, object_type => 'project', file_type => 'attachment') ];
2438 uniq_by { $_->{id} }
2440 +{ id => $_->part->id,
2441 partnumber => $_->part->partnumber }
2442 } @{$self->order->items_sorted};
2444 foreach my $part (@parts) {
2445 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
2446 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
2449 foreach my $key (keys %files) {
2450 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
2456 sub make_periodic_invoices_config_from_yaml {
2457 my ($yaml_config) = @_;
2459 return if !$yaml_config;
2460 my $attr = SL::YAML::Load($yaml_config);
2461 return if 'HASH' ne ref $attr;
2462 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
2466 sub get_periodic_invoices_status {
2467 my ($self, $config) = @_;
2469 return if $self->type ne sales_order_type();
2470 return t8('not configured') if !$config;
2472 my $active = ('HASH' eq ref $config) ? $config->{active}
2473 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
2474 : die "Cannot get status of periodic invoices config";
2476 return $active ? t8('active') : t8('inactive');
2480 my ($self, $action) = @_;
2482 return '' if none { lc($action)} qw(add edit);
2485 # $::locale->text("Add Sales Order");
2486 # $::locale->text("Add Purchase Order");
2487 # $::locale->text("Add Quotation");
2488 # $::locale->text("Add Request for Quotation");
2489 # $::locale->text("Edit Sales Order");
2490 # $::locale->text("Edit Purchase Order");
2491 # $::locale->text("Edit Quotation");
2492 # $::locale->text("Edit Request for Quotation");
2494 $action = ucfirst(lc($action));
2495 return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order")
2496 : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order")
2497 : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation")
2498 : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
2502 sub get_item_cvpartnumber {
2503 my ($self, $item) = @_;
2505 return if !$self->search_cvpartnumber;
2506 return if !$self->order->customervendor;
2508 if ($self->cv eq 'vendor') {
2509 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
2510 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
2511 } elsif ($self->cv eq 'customer') {
2512 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
2513 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
2517 sub get_part_texts {
2518 my ($part_or_id, $language_or_id, %defaults) = @_;
2520 my $part = ref($part_or_id) ? $part_or_id : SL::DB::Part->load_cached($part_or_id);
2521 my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id;
2523 description => $defaults{description} // $part->description,
2524 longdescription => $defaults{longdescription} // $part->notes,
2527 return $texts unless $language_id;
2529 my $translation = SL::DB::Manager::Translation->get_first(
2531 parts_id => $part->id,
2532 language_id => $language_id,
2535 $texts->{description} = $translation->translation if $translation && $translation->translation;
2536 $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription;
2541 sub get_best_price_and_discount_source {
2542 my ($record, $item, $ignore_given) = @_;
2544 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
2547 if ( $item->part->is_assortment ) {
2548 # add assortment items with price 0, as the components carry the price
2549 $price_src = $price_source->price_from_source("");
2550 $price_src->price(0);
2551 } elsif (!$ignore_given && defined $item->sellprice) {
2552 $price_src = $price_source->price_from_source("");
2553 $price_src->price($item->sellprice);
2555 $price_src = $price_source->best_price
2556 ? $price_source->best_price
2557 : $price_source->price_from_source("");
2558 $price_src->price($::form->round_amount($price_src->price / $record->exchangerate, 5)) if $record->exchangerate;
2559 $price_src->price(0) if !$price_source->best_price;
2563 if (!$ignore_given && defined $item->discount) {
2564 $discount_src = $price_source->discount_from_source("");
2565 $discount_src->discount($item->discount);
2567 $discount_src = $price_source->best_discount
2568 ? $price_source->best_discount
2569 : $price_source->discount_from_source("");
2570 $discount_src->discount(0) if !$price_source->best_discount;
2573 return ($price_src, $discount_src);
2576 sub sales_order_type {
2580 sub purchase_order_type {
2584 sub sales_quotation_type {
2588 sub request_quotation_type {
2589 'request_quotation';
2593 return $_[0]->type eq sales_order_type() ? 'ordnumber'
2594 : $_[0]->type eq purchase_order_type() ? 'ordnumber'
2595 : $_[0]->type eq sales_quotation_type() ? 'quonumber'
2596 : $_[0]->type eq request_quotation_type() ? 'quonumber'
2600 sub save_and_redirect_to {
2601 my ($self, %params) = @_;
2603 my $errors = $self->save();
2605 if (scalar @{ $errors }) {
2606 $self->js->flash('error', $_) foreach @{ $errors };
2607 return $self->js->render();
2610 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
2611 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
2612 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
2613 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
2615 flash_later('info', $text);
2617 $self->redirect_to(%params, id => $self->order->id);
2621 my ($self, $addition) = @_;
2623 my $number_type = $self->order->type =~ m{order} ? 'ordnumber' : 'quonumber';
2624 my $snumbers = $number_type . '_' . $self->order->$number_type;
2626 SL::DB::History->new(
2627 trans_id => $self->order->id,
2628 employee_id => SL::DB::Manager::Employee->current->id,
2629 what_done => $self->order->type,
2630 snumbers => $snumbers,
2631 addition => $addition,
2635 sub store_doc_to_webdav_and_filemanagement {
2636 my ($self, $content, $filename, $variant) = @_;
2638 my $order = $self->order;
2641 # copy file to webdav folder
2642 if ($order->number && $::instance_conf->get_webdav_documents) {
2643 my $webdav = SL::Webdav->new(
2644 type => $order->type,
2645 number => $order->number,
2647 my $webdav_file = SL::Webdav::File->new(
2649 filename => $filename,
2652 $webdav_file->store(data => \$content);
2655 push @errors, t8('Storing the document to the WebDAV folder failed: #1', $@);
2658 if ($order->id && $::instance_conf->get_doc_storage) {
2660 SL::File->save(object_id => $order->id,
2661 object_type => $order->type,
2662 mime_type => SL::MIME->mime_type_from_ext($filename),
2663 source => 'created',
2664 file_type => 'document',
2665 file_name => $filename,
2666 file_contents => $content,
2667 print_variant => $variant);
2670 push @errors, t8('Storing the document in the storage backend failed: #1', $@);
2677 sub link_requirement_specs_linking_to_created_from_objects {
2678 my ($self, @converted_from_oe_ids) = @_;
2680 return unless @converted_from_oe_ids;
2682 my $rs_orders = SL::DB::Manager::RequirementSpecOrder->get_all(where => [ order_id => \@converted_from_oe_ids ]);
2683 foreach my $rs_order (@{ $rs_orders }) {
2684 SL::DB::RequirementSpecOrder->new(
2685 order_id => $self->order->id,
2686 requirement_spec_id => $rs_order->requirement_spec_id,
2687 version_id => $rs_order->version_id,
2692 sub set_project_in_linked_requirement_specs {
2695 my $rs_orders = SL::DB::Manager::RequirementSpecOrder->get_all(where => [ order_id => $self->order->id ]);
2696 foreach my $rs_order (@{ $rs_orders }) {
2697 next if $rs_order->requirement_spec->project_id == $self->order->globalproject_id;
2699 $rs_order->requirement_spec->update_attributes(project_id => $self->order->globalproject_id);
2711 SL::Controller::Order - controller for orders
2715 This is a new form to enter orders, completely rewritten with the use
2716 of controller and java script techniques.
2718 The aim is to provide the user a better experience and a faster workflow. Also
2719 the code should be more readable, more reliable and better to maintain.
2727 One input row, so that input happens every time at the same place.
2731 Use of pickers where possible.
2735 Possibility to enter more than one item at once.
2739 Item list in a scrollable area, so that the workflow buttons stay at
2744 Reordering item rows with drag and drop is possible. Sorting item rows is
2745 possible (by partnumber, description, qty, sellprice and discount for now).
2749 No C<update> is necessary. All entries and calculations are managed
2750 with ajax-calls and the page only reloads on C<save>.
2754 User can see changes immediately, because of the use of java script
2765 =item * C<SL/Controller/Order.pm>
2769 =item * C<template/webpages/order/form.html>
2773 =item * C<template/webpages/order/tabs/basic_data.html>
2775 Main tab for basic_data.
2777 This is the only tab here for now. "linked records" and "webdav" tabs are
2778 reused from generic code.
2782 =item * C<template/webpages/order/tabs/_business_info_row.html>
2784 For displaying information on business type
2786 =item * C<template/webpages/order/tabs/_item_input.html>
2788 The input line for items
2790 =item * C<template/webpages/order/tabs/_row.html>
2792 One row for already entered items
2794 =item * C<template/webpages/order/tabs/_tax_row.html>
2796 Displaying tax information
2798 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
2800 Dialog for selecting price and discount sources
2804 =item * C<js/kivi.Order.js>
2806 java script functions
2816 =item * price sources: little symbols showing better price / better discount
2818 =item * select units in input row?
2820 =item * check for direct delivery (workflow sales order -> purchase order)
2822 =item * access rights
2824 =item * display weights
2828 =item * optional client/user behaviour
2830 (transactions has to be set - department has to be set -
2831 force project if enabled in client config)
2835 =head1 KNOWN BUGS AND CAVEATS
2841 No indication that <shift>-up/down expands/collapses second row.
2845 Table header is not sticky in the scrolling area.
2849 Sorting does not include C<position>, neither does reordering.
2851 This behavior was implemented intentionally. But we can discuss, which behavior
2852 should be implemented.
2856 =head1 To discuss / Nice to have
2862 How to expand/collapse second row. Now it can be done clicking the icon or
2867 This controller uses a (changed) copy of the template for the PriceSource
2868 dialog. Maybe there could be used one code source.
2872 Rounding-differences between this controller (PriceTaxCalculator) and the old
2873 form. This is not only a problem here, but also in all parts using the PTC.
2874 There exists a ticket and a patch. This patch should be testet.
2878 An indicator, if the actual inputs are saved (like in an
2879 editor or on text processing application).
2883 A warning when leaving the page without saveing unchanged inputs.
2890 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>