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 $self->{converted_from_oe_id} = delete $::form->{id};
1852 # set item ids to new fake id, to identify them as new items
1853 foreach my $item (@{$self->order->items_sorted}) {
1854 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1858 $::form->{type} = $destination_type;
1859 $self->type($self->init_type);
1860 $self->cv ($self->init_cv);
1864 $self->get_unalterable_data();
1865 $self->pre_render();
1867 # trigger rendering values for second row as hidden, because they
1868 # are loaded only on demand. So we need to keep the values from the
1870 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1874 title => $self->get_title_for('edit'),
1875 %{$self->{template_args}}
1879 sub workflow_sales_or_purchase_order {
1883 my $errors = $self->save();
1885 if (scalar @{ $errors }) {
1886 $self->js->flash('error', $_) foreach @{ $errors };
1887 return $self->js->render();
1890 my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type()
1891 : $::form->{type} eq request_quotation_type() ? purchase_order_type()
1892 : $::form->{type} eq purchase_order_type() ? sales_order_type()
1893 : $::form->{type} eq sales_order_type() ? purchase_order_type()
1896 # check for direct delivery
1897 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
1899 if ( $::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()
1900 && $::form->{use_shipto} && $self->order->shipto) {
1901 $custom_shipto = $self->order->shipto->clone('SL::DB::Order');
1904 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1905 $self->{converted_from_oe_id} = delete $::form->{id};
1907 # set item ids to new fake id, to identify them as new items
1908 foreach my $item (@{$self->order->items_sorted}) {
1909 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1912 if ($::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()) {
1913 if ($::form->{use_shipto}) {
1914 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
1916 # remove any custom shipto if not wanted
1917 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1922 $::form->{type} = $destination_type;
1923 $self->type($self->init_type);
1924 $self->cv ($self->init_cv);
1928 $self->get_unalterable_data();
1929 $self->pre_render();
1931 # trigger rendering values for second row as hidden, because they
1932 # are loaded only on demand. So we need to keep the values from the
1934 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1938 title => $self->get_title_for('edit'),
1939 %{$self->{template_args}}
1947 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1948 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
1949 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1950 $self->{all_languages} = SL::DB::Manager::Language->get_all_sorted();
1951 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1954 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1957 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1959 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1960 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1961 $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
1962 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1963 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1965 my $print_form = Form->new('');
1966 $print_form->{type} = $self->type;
1967 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1968 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1969 form => $print_form,
1970 options => {dialog_name_prefix => 'print_options.',
1974 no_opendocument => 0,
1978 foreach my $item (@{$self->order->orderitems}) {
1979 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1980 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1981 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1984 if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
1985 # Calculate shipped qtys here to prevent calling calculate for every item via the items method.
1986 # Do not use write_to_objects to prevent order->delivered to be set, because this should be
1987 # the value from db, which can be set manually or is set when linked delivery orders are saved.
1988 SL::Helper::ShippedQty->new->calculate($self->order)->write_to(\@{$self->order->items});
1991 if ($self->order->number && $::instance_conf->get_webdav) {
1992 my $webdav = SL::Webdav->new(
1993 type => $self->type,
1994 number => $self->order->number,
1996 my @all_objects = $webdav->get_all_objects;
1997 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1999 link => File::Spec->catfile($_->full_filedescriptor),
2003 if ( (any { $self->type eq $_ } (sales_quotation_type(), sales_order_type()))
2004 && $::instance_conf->get_transport_cost_reminder_article_number_id ) {
2005 $self->{template_args}->{transport_cost_reminder_article} = SL::DB::Part->new(id => $::instance_conf->get_transport_cost_reminder_article_number_id)->load;
2008 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
2010 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.Validator kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
2011 edit_periodic_invoices_config calculate_qty follow_up show_history);
2012 $self->setup_edit_action_bar;
2015 sub setup_edit_action_bar {
2016 my ($self, %params) = @_;
2018 my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
2019 || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
2020 || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
2022 my @req_trans_cost_art = qw(kivi.Order.check_transport_cost_article_presence) x!!$::instance_conf->get_transport_cost_reminder_article_number_id;
2023 my @req_cusordnumber = qw(kivi.Order.check_cusordnumber_presence) x($self->type eq sales_order_type() && $::instance_conf->get_order_warn_no_cusordnumber);
2025 my $has_invoice_for_advance_payment;
2026 if ($self->order->id && $self->type eq sales_order_type()) {
2027 my $lr = $self->order->linked_records(direction => 'to', to => ['Invoice']);
2028 $has_invoice_for_advance_payment = any {'SL::DB::Invoice' eq ref $_ && "invoice_for_advance_payment" eq $_->type} @$lr;
2031 my $has_final_invoice;
2032 if ($self->order->id && $self->type eq sales_order_type()) {
2033 my $lr = $self->order->linked_records(direction => 'to', to => ['Invoice']);
2034 $has_final_invoice = any {'SL::DB::Invoice' eq ref $_ && "final_invoice" eq $_->type} @$lr;
2037 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
2038 my $right = $right_for->{ $self->type };
2039 $right ||= 'DOES_NOT_EXIST';
2040 my $may_edit_create = $::auth->assert($right, 'may fail');
2042 for my $bar ($::request->layout->get('actionbar')) {
2047 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
2048 $::instance_conf->get_order_warn_no_deliverydate,
2050 checks => [ 'kivi.Order.check_save_active_periodic_invoices', ['kivi.validate_form','#order_form'],
2051 @req_trans_cost_art, @req_cusordnumber,
2053 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2057 call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
2058 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2059 @req_trans_cost_art, @req_cusordnumber,
2061 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
2062 : !$self->order->id ? t8('This object has not been saved yet.')
2065 ], # end of combobox "Save"
2072 t8('Save and Quotation'),
2073 submit => [ '#order_form', { action => "Order/sales_quotation" } ],
2074 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2075 only_if => (any { $self->type eq $_ } (sales_order_type())),
2076 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2080 submit => [ '#order_form', { action => "Order/request_for_quotation" } ],
2081 only_if => (any { $self->type eq $_ } (purchase_order_type())),
2082 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2085 t8('Save and Sales Order'),
2086 submit => [ '#order_form', { action => "Order/sales_order" } ],
2087 checks => [ @req_trans_cost_art ],
2088 only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
2089 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2092 t8('Save and Purchase Order'),
2093 call => [ 'kivi.Order.purchase_order_check_for_direct_delivery' ],
2094 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2095 only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
2096 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2099 t8('Save and Delivery Order'),
2100 call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
2101 $::instance_conf->get_order_warn_no_deliverydate,
2103 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2104 @req_trans_cost_art, @req_cusordnumber,
2106 only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())),
2107 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2110 t8('Save and Supplier Delivery Order'),
2111 call => [ 'kivi.Order.save', 'save_and_supplier_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
2112 $::instance_conf->get_order_warn_no_deliverydate,
2114 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2115 @req_trans_cost_art, @req_cusordnumber,
2117 only_if => (any { $self->type eq $_ } (purchase_order_type())),
2118 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2121 t8('Save and Invoice'),
2122 call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
2123 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2124 @req_trans_cost_art, @req_cusordnumber,
2126 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2129 ($has_invoice_for_advance_payment ? t8('Save and Further Invoice for Advance Payment') : t8('Save and Invoice for Advance Payment')),
2130 call => [ 'kivi.Order.save', 'save_and_invoice_for_advance_payment', $::instance_conf->get_order_warn_duplicate_parts ],
2131 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2132 @req_trans_cost_art, @req_cusordnumber,
2134 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
2135 : $has_final_invoice ? t8('This order has already a final invoice.')
2137 only_if => (any { $self->type eq $_ } (sales_order_type())),
2140 t8('Save and Final Invoice'),
2141 call => [ 'kivi.Order.save', 'save_and_final_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
2142 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2143 @req_trans_cost_art, @req_cusordnumber,
2145 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
2146 : $has_final_invoice ? t8('This order has already a final invoice.')
2148 only_if => (any { $self->type eq $_ } (sales_order_type())) && $has_invoice_for_advance_payment,
2151 t8('Save and AP Transaction'),
2152 call => [ 'kivi.Order.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
2153 only_if => (any { $self->type eq $_ } (purchase_order_type())),
2154 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2157 ], # end of combobox "Workflow"
2164 t8('Save and preview PDF'),
2165 call => [ 'kivi.Order.save', 'preview_pdf', $::instance_conf->get_order_warn_duplicate_parts,
2166 $::instance_conf->get_order_warn_no_deliverydate,
2168 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2169 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2172 t8('Save and print'),
2173 call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts,
2174 $::instance_conf->get_order_warn_no_deliverydate,
2176 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2177 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
2180 t8('Save and E-mail'),
2181 id => 'save_and_email_action',
2182 call => [ 'kivi.Order.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts,
2183 $::instance_conf->get_order_warn_no_deliverydate,
2185 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
2186 : !$self->order->id ? t8('This object has not been saved yet.')
2190 t8('Download attachments of all parts'),
2191 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
2192 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
2193 only_if => $::instance_conf->get_doc_storage,
2195 ], # end of combobox "Export"
2199 call => [ 'kivi.Order.delete_order' ],
2200 confirm => $::locale->text('Do you really want to delete this object?'),
2201 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
2202 : !$self->order->id ? t8('This object has not been saved yet.')
2204 only_if => $deletion_allowed,
2212 my ($self, $doc_ref, $params) = @_;
2214 my $order = $self->order;
2217 my $print_form = Form->new('');
2218 $print_form->{type} = $order->type;
2219 $print_form->{formname} = $params->{formname} || $order->type;
2220 $print_form->{format} = $params->{format} || 'pdf';
2221 $print_form->{media} = $params->{media} || 'file';
2222 $print_form->{groupitems} = $params->{groupitems};
2223 $print_form->{printer_id} = $params->{printer_id};
2224 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
2226 $order->language($params->{language});
2227 $order->flatten_to_form($print_form, format_amounts => 1);
2231 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
2232 $template_ext = 'odt';
2233 $template_type = 'OpenDocument';
2234 } elsif ($print_form->{format} =~ m{html}i) {
2235 $template_ext = 'html';
2236 $template_type = 'HTML';
2239 # search for the template
2240 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
2241 name => $print_form->{formname},
2242 extension => $template_ext,
2243 email => $print_form->{media} eq 'email',
2244 language => $params->{language},
2245 printer_id => $print_form->{printer_id},
2248 if (!defined $template_file) {
2249 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);
2252 return @errors if scalar @errors;
2254 $print_form->throw_on_error(sub {
2256 $print_form->prepare_for_printing;
2258 $$doc_ref = SL::Helper::CreatePDF->create_pdf(
2259 format => $print_form->{format},
2260 template_type => $template_type,
2261 template => $template_file,
2262 variables => $print_form,
2263 variable_content_types => {
2264 longdescription => 'html',
2265 partnotes => 'html',
2267 $::form->get_variable_content_types_for_cvars,
2271 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
2277 sub get_files_for_email_dialog {
2280 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
2282 return %files if !$::instance_conf->get_doc_storage;
2284 if ($self->order->id) {
2285 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
2286 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
2287 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
2288 $files{project_files} = [ SL::File->get_all( object_id => $self->order->globalproject_id, object_type => 'project', file_type => 'attachment') ];
2292 uniq_by { $_->{id} }
2294 +{ id => $_->part->id,
2295 partnumber => $_->part->partnumber }
2296 } @{$self->order->items_sorted};
2298 foreach my $part (@parts) {
2299 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
2300 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
2303 foreach my $key (keys %files) {
2304 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
2310 sub make_periodic_invoices_config_from_yaml {
2311 my ($yaml_config) = @_;
2313 return if !$yaml_config;
2314 my $attr = SL::YAML::Load($yaml_config);
2315 return if 'HASH' ne ref $attr;
2316 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
2320 sub get_periodic_invoices_status {
2321 my ($self, $config) = @_;
2323 return if $self->type ne sales_order_type();
2324 return t8('not configured') if !$config;
2326 my $active = ('HASH' eq ref $config) ? $config->{active}
2327 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
2328 : die "Cannot get status of periodic invoices config";
2330 return $active ? t8('active') : t8('inactive');
2334 my ($self, $action) = @_;
2336 return '' if none { lc($action)} qw(add edit);
2339 # $::locale->text("Add Sales Order");
2340 # $::locale->text("Add Purchase Order");
2341 # $::locale->text("Add Quotation");
2342 # $::locale->text("Add Request for Quotation");
2343 # $::locale->text("Edit Sales Order");
2344 # $::locale->text("Edit Purchase Order");
2345 # $::locale->text("Edit Quotation");
2346 # $::locale->text("Edit Request for Quotation");
2348 $action = ucfirst(lc($action));
2349 return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order")
2350 : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order")
2351 : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation")
2352 : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
2356 sub get_item_cvpartnumber {
2357 my ($self, $item) = @_;
2359 return if !$self->search_cvpartnumber;
2360 return if !$self->order->customervendor;
2362 if ($self->cv eq 'vendor') {
2363 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
2364 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
2365 } elsif ($self->cv eq 'customer') {
2366 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
2367 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
2371 sub get_part_texts {
2372 my ($part_or_id, $language_or_id, %defaults) = @_;
2374 my $part = ref($part_or_id) ? $part_or_id : SL::DB::Part->load_cached($part_or_id);
2375 my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id;
2377 description => $defaults{description} // $part->description,
2378 longdescription => $defaults{longdescription} // $part->notes,
2381 return $texts unless $language_id;
2383 my $translation = SL::DB::Manager::Translation->get_first(
2385 parts_id => $part->id,
2386 language_id => $language_id,
2389 $texts->{description} = $translation->translation if $translation && $translation->translation;
2390 $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription;
2395 sub sales_order_type {
2399 sub purchase_order_type {
2403 sub sales_quotation_type {
2407 sub request_quotation_type {
2408 'request_quotation';
2412 return $_[0]->type eq sales_order_type() ? 'ordnumber'
2413 : $_[0]->type eq purchase_order_type() ? 'ordnumber'
2414 : $_[0]->type eq sales_quotation_type() ? 'quonumber'
2415 : $_[0]->type eq request_quotation_type() ? 'quonumber'
2419 sub save_and_redirect_to {
2420 my ($self, %params) = @_;
2422 my $errors = $self->save();
2424 if (scalar @{ $errors }) {
2425 $self->js->flash('error', $_) foreach @{ $errors };
2426 return $self->js->render();
2429 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
2430 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
2431 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
2432 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
2434 flash_later('info', $text);
2436 $self->redirect_to(%params, id => $self->order->id);
2440 my ($self, $addition) = @_;
2442 my $number_type = $self->order->type =~ m{order} ? 'ordnumber' : 'quonumber';
2443 my $snumbers = $number_type . '_' . $self->order->$number_type;
2445 SL::DB::History->new(
2446 trans_id => $self->order->id,
2447 employee_id => SL::DB::Manager::Employee->current->id,
2448 what_done => $self->order->type,
2449 snumbers => $snumbers,
2450 addition => $addition,
2454 sub store_doc_to_webdav_and_filemanagement {
2455 my ($self, $content, $filename, $variant) = @_;
2457 my $order = $self->order;
2460 # copy file to webdav folder
2461 if ($order->number && $::instance_conf->get_webdav_documents) {
2462 my $webdav = SL::Webdav->new(
2463 type => $order->type,
2464 number => $order->number,
2466 my $webdav_file = SL::Webdav::File->new(
2468 filename => $filename,
2471 $webdav_file->store(data => \$content);
2474 push @errors, t8('Storing the document to the WebDAV folder failed: #1', $@);
2477 if ($order->id && $::instance_conf->get_doc_storage) {
2479 SL::File->save(object_id => $order->id,
2480 object_type => $order->type,
2481 mime_type => SL::MIME->mime_type_from_ext($filename),
2482 source => 'created',
2483 file_type => 'document',
2484 file_name => $filename,
2485 file_contents => $content,
2486 print_variant => $variant);
2489 push @errors, t8('Storing the document in the storage backend failed: #1', $@);
2496 sub link_requirement_specs_linking_to_created_from_objects {
2497 my ($self, @converted_from_oe_ids) = @_;
2499 return unless @converted_from_oe_ids;
2501 my $rs_orders = SL::DB::Manager::RequirementSpecOrder->get_all(where => [ order_id => \@converted_from_oe_ids ]);
2502 foreach my $rs_order (@{ $rs_orders }) {
2503 SL::DB::RequirementSpecOrder->new(
2504 order_id => $self->order->id,
2505 requirement_spec_id => $rs_order->requirement_spec_id,
2506 version_id => $rs_order->version_id,
2511 sub set_project_in_linked_requirement_specs {
2514 my $rs_orders = SL::DB::Manager::RequirementSpecOrder->get_all(where => [ order_id => $self->order->id ]);
2515 foreach my $rs_order (@{ $rs_orders }) {
2516 next if $rs_order->requirement_spec->project_id == $self->order->globalproject_id;
2518 $rs_order->requirement_spec->update_attributes(project_id => $self->order->globalproject_id);
2530 SL::Controller::Order - controller for orders
2534 This is a new form to enter orders, completely rewritten with the use
2535 of controller and java script techniques.
2537 The aim is to provide the user a better experience and a faster workflow. Also
2538 the code should be more readable, more reliable and better to maintain.
2546 One input row, so that input happens every time at the same place.
2550 Use of pickers where possible.
2554 Possibility to enter more than one item at once.
2558 Item list in a scrollable area, so that the workflow buttons stay at
2563 Reordering item rows with drag and drop is possible. Sorting item rows is
2564 possible (by partnumber, description, qty, sellprice and discount for now).
2568 No C<update> is necessary. All entries and calculations are managed
2569 with ajax-calls and the page only reloads on C<save>.
2573 User can see changes immediately, because of the use of java script
2584 =item * C<SL/Controller/Order.pm>
2588 =item * C<template/webpages/order/form.html>
2592 =item * C<template/webpages/order/tabs/basic_data.html>
2594 Main tab for basic_data.
2596 This is the only tab here for now. "linked records" and "webdav" tabs are
2597 reused from generic code.
2601 =item * C<template/webpages/order/tabs/_business_info_row.html>
2603 For displaying information on business type
2605 =item * C<template/webpages/order/tabs/_item_input.html>
2607 The input line for items
2609 =item * C<template/webpages/order/tabs/_row.html>
2611 One row for already entered items
2613 =item * C<template/webpages/order/tabs/_tax_row.html>
2615 Displaying tax information
2617 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
2619 Dialog for selecting price and discount sources
2623 =item * C<js/kivi.Order.js>
2625 java script functions
2635 =item * price sources: little symbols showing better price / better discount
2637 =item * select units in input row?
2639 =item * check for direct delivery (workflow sales order -> purchase order)
2641 =item * access rights
2643 =item * display weights
2647 =item * optional client/user behaviour
2649 (transactions has to be set - department has to be set -
2650 force project if enabled in client config)
2654 =head1 KNOWN BUGS AND CAVEATS
2660 Customer discount is not displayed as a valid discount in price source popup
2661 (this might be a bug in price sources)
2663 (I cannot reproduce this (Bernd))
2667 No indication that <shift>-up/down expands/collapses second row.
2671 Inline creation of parts is not currently supported
2675 Table header is not sticky in the scrolling area.
2679 Sorting does not include C<position>, neither does reordering.
2681 This behavior was implemented intentionally. But we can discuss, which behavior
2682 should be implemented.
2686 =head1 To discuss / Nice to have
2692 How to expand/collapse second row. Now it can be done clicking the icon or
2697 Possibility to select PriceSources in input row?
2701 This controller uses a (changed) copy of the template for the PriceSource
2702 dialog. Maybe there could be used one code source.
2706 Rounding-differences between this controller (PriceTaxCalculator) and the old
2707 form. This is not only a problem here, but also in all parts using the PTC.
2708 There exists a ticket and a patch. This patch should be testet.
2712 An indicator, if the actual inputs are saved (like in an
2713 editor or on text processing application).
2717 A warning when leaving the page without saveing unchanged inputs.
2724 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>