1 package SL::Controller::Order;
4 use parent qw(SL::Controller::Base);
6 use SL::Helper::Flash qw(flash_later);
7 use SL::Presenter::Tag qw(select_tag hidden_tag div_tag);
8 use SL::Locale::String qw(t8);
9 use SL::SessionFile::Random;
14 use SL::Util qw(trim);
16 use SL::DB::AdditionalBillingAddress;
23 use SL::DB::PartClassification;
24 use SL::DB::PartsGroup;
27 use SL::DB::RecordLink;
28 use SL::DB::RequirementSpec;
30 use SL::DB::Translation;
32 use SL::Helper::CreatePDF qw(:all);
33 use SL::Helper::PrintOptions;
34 use SL::Helper::ShippedQty;
35 use SL::Helper::UserPreferences::PositionsScrollbar;
36 use SL::Helper::UserPreferences::UpdatePositions;
38 use SL::Controller::Helper::GetModels;
40 use List::Util qw(first sum0);
41 use List::UtilsBy qw(sort_by uniq_by);
42 use List::MoreUtils qw(any none pairwise first_index);
43 use English qw(-no_match_vars);
48 use Rose::Object::MakeMethods::Generic
50 scalar => [ qw(item_ids_to_delete is_custom_shipto_to_delete) ],
51 'scalar --get_set_init' => [ qw(order valid_types type cv p all_price_factors search_cvpartnumber show_update_button part_picker_classification_ids) ],
56 __PACKAGE__->run_before('check_auth');
58 __PACKAGE__->run_before('recalc',
59 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
62 __PACKAGE__->run_before('get_unalterable_data',
63 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
74 $self->order->transdate(DateTime->now_local());
75 my $extra_days = $self->type eq sales_quotation_type() ? $::instance_conf->get_reqdate_interval :
76 $self->type eq sales_order_type() ? $::instance_conf->get_delivery_date_interval : 1;
78 if ( ($self->type eq sales_order_type() && $::instance_conf->get_deliverydate_on)
79 || ($self->type eq sales_quotation_type() && $::instance_conf->get_reqdate_on)
80 && (!$self->order->reqdate)) {
81 $self->order->reqdate(DateTime->today_local->next_workday(extra_days => $extra_days));
88 title => $self->get_title_for('add'),
89 %{$self->{template_args}}
93 # edit an existing order
101 # this is to edit an order from an unsaved order object
103 # set item ids to new fake id, to identify them as new items
104 foreach my $item (@{$self->order->items_sorted}) {
105 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
107 # trigger rendering values for second row as hidden, because they
108 # are loaded only on demand. So we need to keep the values from
110 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
117 title => $self->get_title_for('edit'),
118 %{$self->{template_args}}
122 # edit a collective order (consisting of one or more existing orders)
123 sub action_edit_collective {
127 my @multi_ids = map {
128 $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1}
129 } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form;
131 # fall back to add if no ids are given
132 if (scalar @multi_ids == 0) {
137 # fall back to save as new if only one id is given
138 if (scalar @multi_ids == 1) {
139 $self->order(SL::DB::Order->new(id => $multi_ids[0])->load);
140 $self->action_save_as_new();
144 # make new order from given orders
145 my @multi_orders = map { SL::DB::Order->new(id => $_)->load } @multi_ids;
146 $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders;
147 $self->order(SL::DB::Order->new_from_multi(\@multi_orders, sort_sources_by => 'transdate'));
149 $self->action_edit();
156 my $errors = $self->delete();
158 if (scalar @{ $errors }) {
159 $self->js->flash('error', $_) foreach @{ $errors };
160 return $self->js->render();
163 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been deleted')
164 : $self->type eq purchase_order_type() ? $::locale->text('The order has been deleted')
165 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been deleted')
166 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been deleted')
168 flash_later('info', $text);
170 my @redirect_params = (
175 $self->redirect_to(@redirect_params);
182 my $errors = $self->save();
184 if (scalar @{ $errors }) {
185 $self->js->flash('error', $_) foreach @{ $errors };
186 return $self->js->render();
189 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
190 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
191 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
192 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
194 flash_later('info', $text);
196 my @redirect_params = (
199 id => $self->order->id,
202 $self->redirect_to(@redirect_params);
205 # save the order as new document an open it for edit
206 sub action_save_as_new {
209 my $order = $self->order;
212 $self->js->flash('error', t8('This object has not been saved yet.'));
213 return $self->js->render();
216 # load order from db to check if values changed
217 my $saved_order = SL::DB::Order->new(id => $order->id)->load;
220 # Lets assign a new number if the user hasn't changed the previous one.
221 # If it has been changed manually then use it as-is.
222 $new_attrs{number} = (trim($order->number) eq $saved_order->number)
224 : trim($order->number);
226 # Clear transdate unless changed
227 $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
228 ? DateTime->today_local
231 # Set new reqdate unless changed if it is enabled in client config
232 if ($order->reqdate == $saved_order->reqdate) {
233 my $extra_days = $self->type eq sales_quotation_type() ? $::instance_conf->get_reqdate_interval :
234 $self->type eq sales_order_type() ? $::instance_conf->get_delivery_date_interval : 1;
236 if ( ($self->type eq sales_order_type() && !$::instance_conf->get_deliverydate_on)
237 || ($self->type eq sales_quotation_type() && !$::instance_conf->get_reqdate_on)) {
238 $new_attrs{reqdate} = '';
240 $new_attrs{reqdate} = DateTime->today_local->next_workday(extra_days => $extra_days);
243 $new_attrs{reqdate} = $order->reqdate;
247 $new_attrs{employee} = SL::DB::Manager::Employee->current;
249 # Create new record from current one
250 $self->order(SL::DB::Order->new_from($order, destination_type => $order->type, attributes => \%new_attrs));
252 # no linked records on save as new
253 delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
256 $self->action_save();
261 # This is called if "print" is pressed in the print dialog.
262 # If PDF creation was requested and succeeded, the pdf is offered for download
263 # via send_file (which uses ajax in this case).
267 my $errors = $self->save();
269 if (scalar @{ $errors }) {
270 $self->js->flash('error', $_) foreach @{ $errors };
271 return $self->js->render();
274 $self->js_reset_order_and_item_ids_after_save;
276 my $format = $::form->{print_options}->{format};
277 my $media = $::form->{print_options}->{media};
278 my $formname = $::form->{print_options}->{formname};
279 my $copies = $::form->{print_options}->{copies};
280 my $groupitems = $::form->{print_options}->{groupitems};
281 my $printer_id = $::form->{print_options}->{printer_id};
283 # only PDF, OpenDocument & HTML for now
284 if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf html)) {
285 return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
288 # only screen or printer by now
289 if (none { $media eq $_ } qw(screen printer)) {
290 return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
293 # create a form for generate_attachment_filename
294 my $form = Form->new;
295 $form->{$self->nr_key()} = $self->order->number;
296 $form->{type} = $self->type;
297 $form->{format} = $format;
298 $form->{formname} = $formname;
299 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
300 my $doc_filename = $form->generate_attachment_filename();
303 my @errors = $self->generate_doc(\$doc, { format => $format,
304 formname => $formname,
305 language => $self->order->language,
306 printer_id => $printer_id,
307 groupitems => $groupitems });
308 if (scalar @errors) {
309 return $self->js->flash('error', t8('Generating the document failed: #1', $errors[0]))->render;
312 if ($media eq 'screen') {
314 $self->js->flash('info', t8('The document has been created.'));
317 type => SL::MIME->mime_type_from_ext($doc_filename),
318 name => $doc_filename,
322 } elsif ($media eq 'printer') {
324 my $printer_id = $::form->{print_options}->{printer_id};
325 SL::DB::Printer->new(id => $printer_id)->load->print_document(
330 $self->js->flash('info', t8('The document has been printed.'));
333 my @warnings = $self->store_doc_to_webdav_and_filemanagement($doc, $doc_filename);
334 if (scalar @warnings) {
335 $self->js->flash('warning', $_) for @warnings;
338 $self->save_history('PRINTED');
341 ->run('kivi.ActionBar.setEnabled', '#save_and_email_action')
344 sub action_preview_pdf {
347 my $errors = $self->save();
348 if (scalar @{ $errors }) {
349 $self->js->flash('error', $_) foreach @{ $errors };
350 return $self->js->render();
353 $self->js_reset_order_and_item_ids_after_save;
356 my $media = 'screen';
357 my $formname = $self->type;
360 # create a form for generate_attachment_filename
361 my $form = Form->new;
362 $form->{$self->nr_key()} = $self->order->number;
363 $form->{type} = $self->type;
364 $form->{format} = $format;
365 $form->{formname} = $formname;
366 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
367 my $pdf_filename = $form->generate_attachment_filename();
370 my @errors = $self->generate_doc(\$pdf, { format => $format,
371 formname => $formname,
372 language => $self->order->language,
374 if (scalar @errors) {
375 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
377 $self->save_history('PREVIEWED');
378 $self->js->flash('info', t8('The PDF has been previewed'));
382 type => SL::MIME->mime_type_from_ext($pdf_filename),
383 name => $pdf_filename,
388 # open the email dialog
389 sub action_save_and_show_email_dialog {
392 my $errors = $self->save();
394 if (scalar @{ $errors }) {
395 $self->js->flash('error', $_) foreach @{ $errors };
396 return $self->js->render();
399 my $cv_method = $self->cv;
401 if (!$self->order->$cv_method) {
402 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'))
407 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
408 $email_form->{to} ||= $self->order->$cv_method->email;
409 $email_form->{cc} = $self->order->$cv_method->cc;
410 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
411 # Todo: get addresses from shipto, if any
413 my $form = Form->new;
414 $form->{$self->nr_key()} = $self->order->number;
415 $form->{cusordnumber} = $self->order->cusordnumber;
416 $form->{formname} = $self->type;
417 $form->{type} = $self->type;
418 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
419 $form->{language_id} = $self->order->language->id if $self->order->language;
420 $form->{format} = 'pdf';
421 $form->{cp_id} = $self->order->contact->cp_id if $self->order->contact;
423 $email_form->{subject} = $form->generate_email_subject();
424 $email_form->{attachment_filename} = $form->generate_attachment_filename();
425 $email_form->{message} = $form->generate_email_body();
426 $email_form->{js_send_function} = 'kivi.Order.send_email()';
428 my %files = $self->get_files_for_email_dialog();
430 my @employees_with_email = grep {
431 my $user = SL::DB::Manager::AuthUser->find_by(login => $_->login);
432 $user && !!trim($user->get_config_value('email'));
433 } @{ SL::DB::Manager::Employee->get_all_sorted(query => [ deleted => 0 ]) };
435 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
436 email_form => $email_form,
437 show_bcc => $::auth->assert('email_bcc', 'may fail'),
439 is_customer => $self->cv eq 'customer',
440 ALL_EMPLOYEES => \@employees_with_email,
444 ->run('kivi.Order.show_email_dialog', $dialog_html)
451 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
452 sub action_send_email {
455 my $errors = $self->save();
457 if (scalar @{ $errors }) {
458 $self->js->run('kivi.Order.close_email_dialog');
459 $self->js->flash('error', $_) foreach @{ $errors };
460 return $self->js->render();
463 $self->js_reset_order_and_item_ids_after_save;
465 my $email_form = delete $::form->{email_form};
466 my %field_names = (to => 'email');
468 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
470 # for Form::cleanup which may be called in Form::send_email
471 $::form->{cwd} = getcwd();
472 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
474 $::form->{$_} = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
475 $::form->{media} = 'email';
477 $::form->{attachment_policy} //= '';
479 # Is an old file version available?
481 if ($::form->{attachment_policy} eq 'old_file') {
482 $attfile = SL::File->get_all(object_id => $self->order->id,
483 object_type => $::form->{formname},
484 file_type => 'document');
487 if ($::form->{attachment_policy} ne 'no_file' && !($::form->{attachment_policy} eq 'old_file' && $attfile)) {
489 my @errors = $self->generate_doc(\$doc, {media => $::form->{media},
490 format => $::form->{print_options}->{format},
491 formname => $::form->{print_options}->{formname},
492 language => $self->order->language,
493 printer_id => $::form->{print_options}->{printer_id},
494 groupitems => $::form->{print_options}->{groupitems}});
495 if (scalar @errors) {
496 return $self->js->flash('error', t8('Generating the document failed: #1', $errors[0]))->render($self);
499 my @warnings = $self->store_doc_to_webdav_and_filemanagement($doc, $::form->{attachment_filename});
500 if (scalar @warnings) {
501 flash_later('warning', $_) for @warnings;
504 my $sfile = SL::SessionFile::Random->new(mode => "w");
505 $sfile->fh->print($doc);
508 $::form->{tmpfile} = $sfile->file_name;
509 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
512 $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a linked record to the mail
513 $::form->send_email(\%::myconfig, $::form->{print_options}->{format});
516 my $intnotes = $self->order->intnotes;
517 $intnotes .= "\n\n" if $self->order->intnotes;
518 $intnotes .= t8('[email]') . "\n";
519 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
520 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
521 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
522 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
523 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
524 $intnotes .= t8('Message') . ": " . $::form->{message};
526 $self->order->update_attributes(intnotes => $intnotes);
528 $self->save_history('MAILED');
530 flash_later('info', t8('The email has been sent.'));
532 my @redirect_params = (
535 id => $self->order->id,
538 $self->redirect_to(@redirect_params);
541 # open the periodic invoices config dialog
543 # If there are values in the form (i.e. dialog was opened before),
544 # then use this values. Create new ones, else.
545 sub action_show_periodic_invoices_config_dialog {
548 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
549 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
550 $config ||= SL::DB::PeriodicInvoicesConfig->new(periodicity => 'm',
551 order_value_periodicity => 'p', # = same as periodicity
552 start_date_as_date => $::form->{transdate_as_date} || $::form->current_date,
553 extend_automatically_by => 12,
555 email_subject => GenericTranslations->get(
556 language_id => $::form->{language_id},
557 translation_type =>"preset_text_periodic_invoices_email_subject"),
558 email_body => GenericTranslations->get(
559 language_id => $::form->{language_id},
560 translation_type =>"preset_text_periodic_invoices_email_body"),
562 $config->periodicity('m') if none { $_ eq $config->periodicity } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
563 $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
565 $::form->get_lists(printers => "ALL_PRINTERS",
566 charts => { key => 'ALL_CHARTS',
567 transdate => 'current_date' });
569 $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
571 if ($::form->{customer_id}) {
572 $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
573 my $customer_object = SL::DB::Manager::Customer->find_by(id => $::form->{customer_id});
574 $::form->{postal_invoice} = $customer_object->postal_invoice;
575 $::form->{email_recipient_invoice_address} = $::form->{postal_invoice} ? '' : $customer_object->invoice_mail;
576 $config->send_email(0) if $::form->{postal_invoice};
579 $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
581 popup_js_close_function => 'kivi.Order.close_periodic_invoices_config_dialog()',
582 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
587 # assign the values of the periodic invoices config dialog
588 # as yaml in the hidden tag and set the status.
589 sub action_assign_periodic_invoices_config {
592 $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
594 my $config = { active => $::form->{active} ? 1 : 0,
595 terminated => $::form->{terminated} ? 1 : 0,
596 direct_debit => $::form->{direct_debit} ? 1 : 0,
597 periodicity => (any { $_ eq $::form->{periodicity} } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES) ? $::form->{periodicity} : 'm',
598 order_value_periodicity => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
599 start_date_as_date => $::form->{start_date_as_date},
600 end_date_as_date => $::form->{end_date_as_date},
601 first_billing_date_as_date => $::form->{first_billing_date_as_date},
602 print => $::form->{print} ? 1 : 0,
603 printer_id => $::form->{print} ? $::form->{printer_id} * 1 : undef,
604 copies => $::form->{copies} * 1 ? $::form->{copies} : 1,
605 extend_automatically_by => $::form->{extend_automatically_by} * 1 || undef,
606 ar_chart_id => $::form->{ar_chart_id} * 1,
607 send_email => $::form->{send_email} ? 1 : 0,
608 email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
609 email_recipient_address => $::form->{email_recipient_address},
610 email_sender => $::form->{email_sender},
611 email_subject => $::form->{email_subject},
612 email_body => $::form->{email_body},
615 my $periodic_invoices_config = SL::YAML::Dump($config);
617 my $status = $self->get_periodic_invoices_status($config);
620 ->remove('#order_periodic_invoices_config')
621 ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
622 ->run('kivi.Order.close_periodic_invoices_config_dialog')
623 ->html('#periodic_invoices_status', $status)
624 ->flash('info', t8('The periodic invoices config has been assigned.'))
628 sub action_get_has_active_periodic_invoices {
631 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
632 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
634 my $has_active_periodic_invoices =
635 $self->type eq sales_order_type()
638 && (!$config->end_date || ($config->end_date > DateTime->today_local))
639 && $config->get_previous_billed_period_start_date;
641 $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
644 # save the order and redirect to the frontend subroutine for a new
646 sub action_save_and_delivery_order {
649 $self->save_and_redirect_to(
650 controller => 'oe.pl',
651 action => 'oe_delivery_order_from_order',
655 # save the order and redirect to the frontend subroutine for a new
657 sub action_save_and_invoice {
660 $self->save_and_redirect_to(
661 controller => 'oe.pl',
662 action => 'oe_invoice_from_order',
666 # workflow from sales order to sales quotation
667 sub action_sales_quotation {
668 $_[0]->workflow_sales_or_request_for_quotation();
671 # workflow from sales order to sales quotation
672 sub action_request_for_quotation {
673 $_[0]->workflow_sales_or_request_for_quotation();
676 # workflow from sales quotation to sales order
677 sub action_sales_order {
678 $_[0]->workflow_sales_or_purchase_order();
681 # workflow from rfq to purchase order
682 sub action_purchase_order {
683 $_[0]->workflow_sales_or_purchase_order();
686 # workflow from purchase order to ap transaction
687 sub action_save_and_ap_transaction {
690 $self->save_and_redirect_to(
691 controller => 'ap.pl',
692 action => 'add_from_purchase_order',
696 # set form elements in respect to a changed customer or vendor
698 # This action is called on an change of the customer/vendor picker.
699 sub action_customer_vendor_changed {
702 setup_order_from_cv($self->order);
705 my $cv_method = $self->cv;
707 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
708 $self->js->show('#cp_row');
710 $self->js->hide('#cp_row');
713 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
714 $self->js->show('#shipto_selection');
716 $self->js->hide('#shipto_selection');
719 if ($cv_method eq 'customer') {
720 my $show_hide = scalar @{ $self->order->customer->additional_billing_addresses } > 0 ? 'show' : 'hide';
721 $self->js->$show_hide('#billing_address_row');
724 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
727 ->replaceWith('#order_cp_id', $self->build_contact_select)
728 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
729 ->replaceWith('#shipto_inputs ', $self->build_shipto_inputs)
730 ->replaceWith('#order_billing_address_id', $self->build_billing_address_select)
731 ->replaceWith('#business_info_row', $self->build_business_info_row)
732 ->val( '#order_taxzone_id', $self->order->taxzone_id)
733 ->val( '#order_taxincluded', $self->order->taxincluded)
734 ->val( '#order_currency_id', $self->order->currency_id)
735 ->val( '#order_payment_id', $self->order->payment_id)
736 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
737 ->val( '#order_intnotes', $self->order->intnotes)
738 ->val( '#order_language_id', $self->order->$cv_method->language_id)
739 ->focus( '#order_' . $self->cv . '_id')
740 ->run('kivi.Order.update_exchangerate');
742 $self->js_redisplay_amounts_and_taxes;
743 $self->js_redisplay_cvpartnumbers;
747 # open the dialog for customer/vendor details
748 sub action_show_customer_vendor_details_dialog {
751 my $is_customer = 'customer' eq $::form->{vc};
754 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
756 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
759 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
760 $details{discount_as_percent} = $cv->discount_as_percent;
761 $details{creditlimt} = $cv->creditlimit_as_number;
762 $details{business} = $cv->business->description if $cv->business;
763 $details{language} = $cv->language_obj->description if $cv->language_obj;
764 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
765 $details{payment_terms} = $cv->payment->description if $cv->payment;
766 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
768 foreach my $entry (@{ $cv->additional_billing_addresses }) {
769 push @{ $details{ADDITIONAL_BILLING_ADDRESSES} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
771 foreach my $entry (@{ $cv->shipto }) {
772 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
774 foreach my $entry (@{ $cv->contacts }) {
775 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
778 $_[0]->render('common/show_vc_details', { layout => 0 },
779 is_customer => $is_customer,
784 # called if a unit in an existing item row is changed
785 sub action_unit_changed {
788 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
789 my $item = $self->order->items_sorted->[$idx];
791 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
792 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
797 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
798 $self->js_redisplay_line_values;
799 $self->js_redisplay_amounts_and_taxes;
803 # add an item row for a new item entered in the input row
804 sub action_add_item {
807 delete $::form->{add_item}->{create_part_type};
809 my $form_attr = $::form->{add_item};
811 return unless $form_attr->{parts_id};
813 my $item = new_item($self->order, $form_attr);
815 $self->order->add_items($item);
819 $self->get_item_cvpartnumber($item);
821 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
822 my $row_as_html = $self->p->render('order/tabs/_row',
828 if ($::form->{insert_before_item_id}) {
830 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
833 ->append('#row_table_id', $row_as_html);
836 if ( $item->part->is_assortment ) {
837 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
838 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
839 my $attr = { parts_id => $assortment_item->parts_id,
840 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
841 unit => $assortment_item->unit,
842 description => $assortment_item->part->description,
844 my $item = new_item($self->order, $attr);
846 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
847 $item->discount(1) unless $assortment_item->charge;
849 $self->order->add_items( $item );
851 $self->get_item_cvpartnumber($item);
852 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
853 my $row_as_html = $self->p->render('order/tabs/_row',
858 if ($::form->{insert_before_item_id}) {
860 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
863 ->append('#row_table_id', $row_as_html);
869 ->val('.add_item_input', '')
870 ->run('kivi.Order.init_row_handlers')
871 ->run('kivi.Order.renumber_positions')
872 ->focus('#add_item_parts_id_name');
874 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
876 $self->js_redisplay_amounts_and_taxes;
880 # add item rows for multiple items at once
881 sub action_add_multi_items {
884 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
885 return $self->js->render() unless scalar @form_attr;
888 foreach my $attr (@form_attr) {
889 my $item = new_item($self->order, $attr);
891 if ( $item->part->is_assortment ) {
892 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
893 my $attr = { parts_id => $assortment_item->parts_id,
894 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
895 unit => $assortment_item->unit,
896 description => $assortment_item->part->description,
898 my $item = new_item($self->order, $attr);
900 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
901 $item->discount(1) unless $assortment_item->charge;
906 $self->order->add_items(@items);
910 foreach my $item (@items) {
911 $self->get_item_cvpartnumber($item);
912 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
913 my $row_as_html = $self->p->render('order/tabs/_row',
919 if ($::form->{insert_before_item_id}) {
921 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
924 ->append('#row_table_id', $row_as_html);
929 ->run('kivi.Part.close_picker_dialogs')
930 ->run('kivi.Order.init_row_handlers')
931 ->run('kivi.Order.renumber_positions')
932 ->focus('#add_item_parts_id_name');
934 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
936 $self->js_redisplay_amounts_and_taxes;
940 # recalculate all linetotals, amounts and taxes and redisplay them
941 sub action_recalc_amounts_and_taxes {
946 $self->js_redisplay_line_values;
947 $self->js_redisplay_amounts_and_taxes;
951 sub action_update_exchangerate {
955 is_standard => $self->order->currency_id == $::instance_conf->get_currency_id,
956 currency_name => $self->order->currency->name,
957 exchangerate => $self->order->daily_exchangerate_as_null_number,
960 $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
963 # redisplay item rows if they are sorted by an attribute
964 sub action_reorder_items {
968 partnumber => sub { $_[0]->part->partnumber },
969 description => sub { $_[0]->description },
970 qty => sub { $_[0]->qty },
971 sellprice => sub { $_[0]->sellprice },
972 discount => sub { $_[0]->discount },
973 cvpartnumber => sub { $_[0]->{cvpartnumber} },
976 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
978 my $method = $sort_keys{$::form->{order_by}};
979 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
980 if ($::form->{sort_dir}) {
981 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
982 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
984 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
987 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
988 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
990 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
994 ->run('kivi.Order.redisplay_items', \@to_sort)
998 # show the popup to choose a price/discount source
999 sub action_price_popup {
1002 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
1003 my $item = $self->order->items_sorted->[$idx];
1005 $self->render_price_dialog($item);
1008 # save the order in a session variable and redirect to the part controller
1009 sub action_create_part {
1012 my $previousform = $::auth->save_form_in_session(non_scalars => 1);
1014 my $callback = $self->url_for(
1015 action => 'return_from_create_part',
1016 type => $self->type, # type is needed for check_auth on return
1017 previousform => $previousform,
1020 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.'));
1022 my @redirect_params = (
1023 controller => 'Part',
1025 part_type => $::form->{add_item}->{create_part_type},
1026 callback => $callback,
1030 $self->redirect_to(@redirect_params);
1033 sub action_return_from_create_part {
1036 $self->{created_part} = SL::DB::Part->new(id => delete $::form->{new_parts_id})->load if $::form->{new_parts_id};
1038 $::auth->restore_form_from_session(delete $::form->{previousform});
1040 # set item ids to new fake id, to identify them as new items
1041 foreach my $item (@{$self->order->items_sorted}) {
1042 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1046 $self->get_unalterable_data();
1047 $self->pre_render();
1049 # trigger rendering values for second row/longdescription as hidden,
1050 # because they are loaded only on demand. So we need to keep the values
1052 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1053 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
1057 title => $self->get_title_for('edit'),
1058 %{$self->{template_args}}
1063 # load the second row for one or more items
1065 # This action gets the html code for all items second rows by rendering a template for
1066 # the second row and sets the html code via client js.
1067 sub action_load_second_rows {
1070 $self->recalc() if $self->order->is_sales; # for margin calculation
1072 foreach my $item_id (@{ $::form->{item_ids} }) {
1073 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1074 my $item = $self->order->items_sorted->[$idx];
1076 $self->js_load_second_row($item, $item_id, 0);
1079 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
1081 $self->js->render();
1084 # update description, notes and sellprice from master data
1085 sub action_update_row_from_master_data {
1088 foreach my $item_id (@{ $::form->{item_ids} }) {
1089 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1090 my $item = $self->order->items_sorted->[$idx];
1091 my $texts = get_part_texts($item->part, $self->order->language_id);
1093 $item->description($texts->{description});
1094 $item->longdescription($texts->{longdescription});
1096 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1099 if ($item->part->is_assortment) {
1100 # add assortment items with price 0, as the components carry the price
1101 $price_src = $price_source->price_from_source("");
1102 $price_src->price(0);
1104 $price_src = $price_source->best_price
1105 ? $price_source->best_price
1106 : $price_source->price_from_source("");
1107 $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate;
1108 $price_src->price(0) if !$price_source->best_price;
1112 $item->sellprice($price_src->price);
1113 $item->active_price_source($price_src);
1116 ->run('kivi.Order.update_sellprice', $item_id, $item->sellprice_as_number)
1117 ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
1118 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
1119 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
1121 if ($self->search_cvpartnumber) {
1122 $self->get_item_cvpartnumber($item);
1123 $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
1128 $self->js_redisplay_line_values;
1129 $self->js_redisplay_amounts_and_taxes;
1131 $self->js->render();
1134 sub js_load_second_row {
1135 my ($self, $item, $item_id, $do_parse) = @_;
1138 # Parse values from form (they are formated while rendering (template)).
1139 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1140 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1141 foreach my $var (@{ $item->cvars_by_config }) {
1142 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1144 $item->parse_custom_variable_values;
1147 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1150 ->html('#second_row_' . $item_id, $row_as_html)
1151 ->data('#second_row_' . $item_id, 'loaded', 1);
1154 sub js_redisplay_line_values {
1157 my $is_sales = $self->order->is_sales;
1159 # sales orders with margins
1164 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1165 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1166 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1167 ]} @{ $self->order->items_sorted };
1171 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1172 ]} @{ $self->order->items_sorted };
1176 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1179 sub js_redisplay_amounts_and_taxes {
1182 if (scalar @{ $self->{taxes} }) {
1183 $self->js->show('#taxincluded_row_id');
1185 $self->js->hide('#taxincluded_row_id');
1188 if ($self->order->taxincluded) {
1189 $self->js->hide('#subtotal_row_id');
1191 $self->js->show('#subtotal_row_id');
1194 if ($self->order->is_sales) {
1195 my $is_neg = $self->order->marge_total < 0;
1197 ->html('#marge_total_id', $::form->format_amount(\%::myconfig, $self->order->marge_total, 2))
1198 ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
1199 ->action_if( $is_neg, 'addClass', '#marge_total_id', 'plus0')
1200 ->action_if( $is_neg, 'addClass', '#marge_percent_id', 'plus0')
1201 ->action_if( $is_neg, 'addClass', '#marge_percent_sign_id', 'plus0')
1202 ->action_if(!$is_neg, 'removeClass', '#marge_total_id', 'plus0')
1203 ->action_if(!$is_neg, 'removeClass', '#marge_percent_id', 'plus0')
1204 ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
1208 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
1209 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
1210 ->remove('.tax_row')
1211 ->insertBefore($self->build_tax_rows, '#amount_row_id');
1214 sub js_redisplay_cvpartnumbers {
1217 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1219 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1222 ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
1225 sub js_reset_order_and_item_ids_after_save {
1229 ->val('#id', $self->order->id)
1230 ->val('#converted_from_oe_id', '')
1231 ->val('#order_' . $self->nr_key(), $self->order->number);
1234 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1235 next if !$self->order->items_sorted->[$idx]->id;
1236 next if $form_item_id !~ m{^new};
1238 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1239 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1240 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1244 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1251 sub init_valid_types {
1252 [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
1258 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1259 die "Not a valid type for order";
1262 $self->type($::form->{type});
1268 my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer'
1269 : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
1270 : die "Not a valid type for order";
1275 sub init_search_cvpartnumber {
1278 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1279 my $search_cvpartnumber;
1280 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1281 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1283 return $search_cvpartnumber;
1286 sub init_show_update_button {
1289 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1300 sub init_all_price_factors {
1301 SL::DB::Manager::PriceFactor->get_all;
1304 sub init_part_picker_classification_ids {
1306 my $attribute = 'used_for_' . ($self->type =~ m{sales} ? 'sale' : 'purchase');
1308 return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => [ $attribute => 1 ]) } ];
1314 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
1316 my $right = $right_for->{ $self->type };
1317 $right ||= 'DOES_NOT_EXIST';
1319 $::auth->assert($right);
1322 # build the selection box for contacts
1324 # Needed, if customer/vendor changed.
1325 sub build_contact_select {
1328 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1329 value_key => 'cp_id',
1330 title_key => 'full_name_dep',
1331 default => $self->order->cp_id,
1333 style => 'width: 300px',
1337 # build the selection box for the additional billing address
1339 # Needed, if customer/vendor changed.
1340 sub build_billing_address_select {
1343 select_tag('order.billing_address_id',
1344 [ {displayable_id => '', id => ''}, $self->order->{$self->cv}->additional_billing_addresses ],
1346 title_key => 'displayable_id',
1347 default => $self->order->billing_address_id,
1349 style => 'width: 300px',
1353 # build the selection box for shiptos
1355 # Needed, if customer/vendor changed.
1356 sub build_shipto_select {
1359 select_tag('order.shipto_id',
1360 [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
1361 value_key => 'shipto_id',
1362 title_key => 'displayable_id',
1363 default => $self->order->shipto_id,
1365 style => 'width: 300px',
1369 # build the inputs for the cusom shipto dialog
1371 # Needed, if customer/vendor changed.
1372 sub build_shipto_inputs {
1375 my $content = $self->p->render('common/_ship_to_dialog',
1376 vc_obj => $self->order->customervendor,
1377 cs_obj => $self->order->custom_shipto,
1378 cvars => $self->order->custom_shipto->cvars_by_config,
1379 id_selector => '#order_shipto_id');
1381 div_tag($content, id => 'shipto_inputs');
1384 # render the info line for business
1386 # Needed, if customer/vendor changed.
1387 sub build_business_info_row
1389 $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
1392 # build the rows for displaying taxes
1394 # Called if amounts where recalculated and redisplayed.
1395 sub build_tax_rows {
1399 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
1400 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
1402 return $rows_as_html;
1406 sub render_price_dialog {
1407 my ($self, $record_item) = @_;
1409 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1413 'kivi.io.price_chooser_dialog',
1414 t8('Available Prices'),
1415 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1420 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1421 # $self->js->show('#dialog_flash_error');
1430 return if !$::form->{id};
1432 $self->order(SL::DB::Order->new(id => $::form->{id})->load);
1434 # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
1435 # You need a custom shipto object to call cvars_by_config to get the cvars.
1436 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
1438 return $self->order;
1441 # load or create a new order object
1443 # And assign changes from the form to this object.
1444 # If the order is loaded from db, check if items are deleted in the form,
1445 # remove them form the object and collect them for removing from db on saving.
1446 # Then create/update items from form (via make_item) and add them.
1450 # add_items adds items to an order with no items for saving, but they cannot
1451 # be retrieved via items until the order is saved. Adding empty items to new
1452 # order here solves this problem.
1454 $order = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1455 $order ||= SL::DB::Order->new(orderitems => [],
1456 quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())),
1457 currency_id => $::instance_conf->get_currency_id(),);
1459 my $cv_id_method = $self->cv . '_id';
1460 if (!$::form->{id} && $::form->{$cv_id_method}) {
1461 $order->$cv_id_method($::form->{$cv_id_method});
1462 setup_order_from_cv($order);
1465 my $form_orderitems = delete $::form->{order}->{orderitems};
1466 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1468 $order->assign_attributes(%{$::form->{order}});
1470 $self->setup_custom_shipto_from_form($order, $::form);
1472 if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) {
1473 my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
1474 $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
1477 # remove deleted items
1478 $self->item_ids_to_delete([]);
1479 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1480 my $item = $order->orderitems->[$idx];
1481 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1482 splice @{$order->orderitems}, $idx, 1;
1483 push @{$self->item_ids_to_delete}, $item->id;
1489 foreach my $form_attr (@{$form_orderitems}) {
1490 my $item = make_item($order, $form_attr);
1491 $item->position($pos);
1495 $order->add_items(grep {!$_->id} @items);
1500 # create or update items from form
1502 # Make item objects from form values. For items already existing read from db.
1503 # Create a new item else. And assign attributes.
1505 my ($record, $attr) = @_;
1508 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1510 my $is_new = !$item;
1512 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1513 # they cannot be retrieved via custom_variables until the order/orderitem is
1514 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1515 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1517 $item->assign_attributes(%$attr);
1520 my $texts = get_part_texts($item->part, $record->language_id);
1521 $item->longdescription($texts->{longdescription}) if !defined $attr->{longdescription};
1522 $item->project_id($record->globalproject_id) if !defined $attr->{project_id};
1523 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number};
1531 # This is used to add one item
1533 my ($record, $attr) = @_;
1535 my $item = SL::DB::OrderItem->new;
1537 # Remove attributes where the user left or set the inputs empty.
1538 # So these attributes will be undefined and we can distinguish them
1539 # from zero later on.
1540 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1541 delete $attr->{$_} if $attr->{$_} eq '';
1544 $item->assign_attributes(%$attr);
1546 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1547 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1549 $item->unit($part->unit) if !$item->unit;
1552 if ( $part->is_assortment ) {
1553 # add assortment items with price 0, as the components carry the price
1554 $price_src = $price_source->price_from_source("");
1555 $price_src->price(0);
1556 } elsif (defined $item->sellprice) {
1557 $price_src = $price_source->price_from_source("");
1558 $price_src->price($item->sellprice);
1560 $price_src = $price_source->best_price
1561 ? $price_source->best_price
1562 : $price_source->price_from_source("");
1563 $price_src->price($::form->round_amount($price_src->price / $record->exchangerate, 5)) if $record->exchangerate;
1564 $price_src->price(0) if !$price_source->best_price;
1568 if (defined $item->discount) {
1569 $discount_src = $price_source->discount_from_source("");
1570 $discount_src->discount($item->discount);
1572 $discount_src = $price_source->best_discount
1573 ? $price_source->best_discount
1574 : $price_source->discount_from_source("");
1575 $discount_src->discount(0) if !$price_source->best_discount;
1579 $new_attr{part} = $part;
1580 $new_attr{description} = $part->description if ! $item->description;
1581 $new_attr{qty} = 1.0 if ! $item->qty;
1582 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1583 $new_attr{sellprice} = $price_src->price;
1584 $new_attr{discount} = $discount_src->discount;
1585 $new_attr{active_price_source} = $price_src;
1586 $new_attr{active_discount_source} = $discount_src;
1587 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1588 $new_attr{project_id} = $record->globalproject_id;
1589 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1591 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1592 # they cannot be retrieved via custom_variables until the order/orderitem is
1593 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1594 $new_attr{custom_variables} = [];
1596 my $texts = get_part_texts($part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription});
1598 $item->assign_attributes(%new_attr, %{ $texts });
1603 sub setup_order_from_cv {
1606 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id));
1608 $order->intnotes($order->customervendor->notes);
1610 return if !$order->is_sales;
1612 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1613 $order->taxincluded(defined($order->customer->taxincluded_checked)
1614 ? $order->customer->taxincluded_checked
1615 : $::myconfig{taxincluded_checked});
1617 my $address = $order->customer->default_billing_address;;
1618 $order->billing_address_id($address ? $address->id : undef);
1621 # setup custom shipto from form
1623 # The dialog returns form variables starting with 'shipto' and cvars starting
1624 # with 'shiptocvar_'.
1625 # Mark it to be deleted if a shipto from master data is selected
1626 # (i.e. order has a shipto).
1627 # Else, update or create a new custom shipto. If the fields are empty, it
1628 # will not be saved on save.
1629 sub setup_custom_shipto_from_form {
1630 my ($self, $order, $form) = @_;
1632 if ($order->shipto) {
1633 $self->is_custom_shipto_to_delete(1);
1635 my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1637 my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
1638 my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
1640 $custom_shipto->assign_attributes(%$shipto_attrs);
1641 $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
1645 # recalculate prices and taxes
1647 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1651 my %pat = $self->order->calculate_prices_and_taxes();
1653 $self->{taxes} = [];
1654 foreach my $tax_id (keys %{ $pat{taxes_by_tax_id} }) {
1655 my $netamount = sum0 map { $pat{amounts}->{$_}->{amount} } grep { $pat{amounts}->{$_}->{tax_id} == $tax_id } keys %{ $pat{amounts} };
1657 push(@{ $self->{taxes} }, { amount => $pat{taxes_by_tax_id}->{$tax_id},
1658 netamount => $netamount,
1659 tax => SL::DB::Tax->new(id => $tax_id)->load });
1661 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items_sorted}, @{$pat{items}};
1664 # get data for saving, printing, ..., that is not changed in the form
1666 # Only cvars for now.
1667 sub get_unalterable_data {
1670 foreach my $item (@{ $self->order->items }) {
1671 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1672 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1673 foreach my $var (@{ $item->cvars_by_config }) {
1674 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1676 $item->parse_custom_variable_values;
1682 # And remove related files in the spool directory
1687 my $db = $self->order->db;
1689 $db->with_transaction(
1691 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1692 $self->order->delete;
1693 my $spool = $::lx_office_conf{paths}->{spool};
1694 unlink map { "$spool/$_" } @spoolfiles if $spool;
1696 $self->save_history('DELETED');
1699 }) || push(@{$errors}, $db->error);
1706 # And delete items that are deleted in the form.
1711 my $db = $self->order->db;
1713 $db->with_transaction(sub {
1714 # delete custom shipto if it is to be deleted or if it is empty
1715 if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
1716 $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
1717 $self->order->custom_shipto(undef);
1720 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1721 $self->order->save(cascade => 1);
1724 if ($::form->{converted_from_oe_id}) {
1725 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1727 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1728 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1729 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
1730 $src->link_to_record($self->order);
1732 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1734 foreach (@{ $self->order->items_sorted }) {
1735 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1737 SL::DB::RecordLink->new(from_table => 'orderitems',
1738 from_id => $from_id,
1739 to_table => 'orderitems',
1746 $self->link_requirement_specs_linking_to_created_from_objects(@converted_from_oe_ids);
1749 $self->set_project_in_linked_requirement_specs if $self->order->globalproject_id;
1751 $self->save_history('SAVED');
1754 }) || push(@{$errors}, $db->error);
1759 sub workflow_sales_or_request_for_quotation {
1763 my $errors = $self->save();
1765 if (scalar @{ $errors }) {
1766 $self->js->flash('error', $_) for @{ $errors };
1767 return $self->js->render();
1770 my $destination_type = $::form->{type} eq sales_order_type() ? sales_quotation_type() : request_quotation_type();
1772 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1773 $self->{converted_from_oe_id} = delete $::form->{id};
1775 # set item ids to new fake id, to identify them as new items
1776 foreach my $item (@{$self->order->items_sorted}) {
1777 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1781 $::form->{type} = $destination_type;
1782 $self->type($self->init_type);
1783 $self->cv ($self->init_cv);
1787 $self->get_unalterable_data();
1788 $self->pre_render();
1790 # trigger rendering values for second row as hidden, because they
1791 # are loaded only on demand. So we need to keep the values from the
1793 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1797 title => $self->get_title_for('edit'),
1798 %{$self->{template_args}}
1802 sub workflow_sales_or_purchase_order {
1806 my $errors = $self->save();
1808 if (scalar @{ $errors }) {
1809 $self->js->flash('error', $_) foreach @{ $errors };
1810 return $self->js->render();
1813 my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type()
1814 : $::form->{type} eq request_quotation_type() ? purchase_order_type()
1815 : $::form->{type} eq purchase_order_type() ? sales_order_type()
1816 : $::form->{type} eq sales_order_type() ? purchase_order_type()
1819 # check for direct delivery
1820 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
1822 if ( $::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()
1823 && $::form->{use_shipto} && $self->order->shipto) {
1824 $custom_shipto = $self->order->shipto->clone('SL::DB::Order');
1827 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1828 $self->{converted_from_oe_id} = delete $::form->{id};
1830 # set item ids to new fake id, to identify them as new items
1831 foreach my $item (@{$self->order->items_sorted}) {
1832 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1835 if ($::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()) {
1836 if ($::form->{use_shipto}) {
1837 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
1839 # remove any custom shipto if not wanted
1840 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1845 $::form->{type} = $destination_type;
1846 $self->type($self->init_type);
1847 $self->cv ($self->init_cv);
1851 $self->get_unalterable_data();
1852 $self->pre_render();
1854 # trigger rendering values for second row as hidden, because they
1855 # are loaded only on demand. So we need to keep the values from the
1857 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1861 title => $self->get_title_for('edit'),
1862 %{$self->{template_args}}
1870 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1871 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
1872 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1873 $self->{all_languages} = SL::DB::Manager::Language->get_all_sorted();
1874 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1877 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1880 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1882 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1883 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1884 $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
1885 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1886 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1888 my $print_form = Form->new('');
1889 $print_form->{type} = $self->type;
1890 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1891 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1892 form => $print_form,
1893 options => {dialog_name_prefix => 'print_options.',
1897 no_opendocument => 0,
1901 foreach my $item (@{$self->order->orderitems}) {
1902 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1903 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1904 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1907 if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
1908 # Calculate shipped qtys here to prevent calling calculate for every item via the items method.
1909 # Do not use write_to_objects to prevent order->delivered to be set, because this should be
1910 # the value from db, which can be set manually or is set when linked delivery orders are saved.
1911 SL::Helper::ShippedQty->new->calculate($self->order)->write_to(\@{$self->order->items});
1914 if ($self->order->number && $::instance_conf->get_webdav) {
1915 my $webdav = SL::Webdav->new(
1916 type => $self->type,
1917 number => $self->order->number,
1919 my @all_objects = $webdav->get_all_objects;
1920 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1922 link => File::Spec->catfile($_->full_filedescriptor),
1926 if ( (any { $self->type eq $_ } (sales_quotation_type(), sales_order_type()))
1927 && $::instance_conf->get_transport_cost_reminder_article_number_id ) {
1928 $self->{template_args}->{transport_cost_reminder_article} = SL::DB::Part->new(id => $::instance_conf->get_transport_cost_reminder_article_number_id)->load;
1931 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1933 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.Validator kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
1934 edit_periodic_invoices_config calculate_qty follow_up show_history);
1935 $self->setup_edit_action_bar;
1938 sub setup_edit_action_bar {
1939 my ($self, %params) = @_;
1941 my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
1942 || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
1943 || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
1945 my @req_trans_cost_art = qw(kivi.Order.check_transport_cost_article_presence) x!!$::instance_conf->get_transport_cost_reminder_article_number_id;
1946 my @req_cusordnumber = qw(kivi.Order.check_cusordnumber_presence) x($self->type eq sales_order_type() && $::instance_conf->get_order_warn_no_cusordnumber);
1948 for my $bar ($::request->layout->get('actionbar')) {
1953 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1954 $::instance_conf->get_order_warn_no_deliverydate,
1956 checks => [ 'kivi.Order.check_save_active_periodic_invoices', ['kivi.validate_form','#order_form'],
1957 @req_trans_cost_art, @req_cusordnumber,
1962 call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1963 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
1964 @req_trans_cost_art, @req_cusordnumber,
1966 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1968 ], # end of combobox "Save"
1975 t8('Save and Quotation'),
1976 submit => [ '#order_form', { action => "Order/sales_quotation" } ],
1977 checks => [ @req_trans_cost_art, @req_cusordnumber ],
1978 only_if => (any { $self->type eq $_ } (sales_order_type())),
1982 submit => [ '#order_form', { action => "Order/request_for_quotation" } ],
1983 only_if => (any { $self->type eq $_ } (purchase_order_type())),
1986 t8('Save and Sales Order'),
1987 submit => [ '#order_form', { action => "Order/sales_order" } ],
1988 checks => [ @req_trans_cost_art ],
1989 only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
1992 t8('Save and Purchase Order'),
1993 call => [ 'kivi.Order.purchase_order_check_for_direct_delivery' ],
1994 checks => [ @req_trans_cost_art, @req_cusordnumber ],
1995 only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
1998 t8('Save and Delivery Order'),
1999 call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
2000 $::instance_conf->get_order_warn_no_deliverydate,
2002 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2003 @req_trans_cost_art, @req_cusordnumber,
2005 only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type()))
2008 t8('Save and Invoice'),
2009 call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
2010 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2011 @req_trans_cost_art, @req_cusordnumber,
2015 t8('Save and AP Transaction'),
2016 call => [ 'kivi.Order.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
2017 only_if => (any { $self->type eq $_ } (purchase_order_type()))
2020 ], # end of combobox "Workflow"
2027 t8('Save and preview PDF'),
2028 call => [ 'kivi.Order.save', 'preview_pdf', $::instance_conf->get_order_warn_duplicate_parts,
2029 $::instance_conf->get_order_warn_no_deliverydate,
2031 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2034 t8('Save and print'),
2035 call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts,
2036 $::instance_conf->get_order_warn_no_deliverydate,
2038 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2041 t8('Save and E-mail'),
2042 id => 'save_and_email_action',
2043 call => [ 'kivi.Order.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts,
2044 $::instance_conf->get_order_warn_no_deliverydate,
2046 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
2049 t8('Download attachments of all parts'),
2050 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
2051 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
2052 only_if => $::instance_conf->get_doc_storage,
2054 ], # end of combobox "Export"
2058 call => [ 'kivi.Order.delete_order' ],
2059 confirm => $::locale->text('Do you really want to delete this object?'),
2060 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
2061 only_if => $deletion_allowed,
2070 call => [ 'set_history_window', $self->order->id, 'id' ],
2071 disabled => !$self->order->id ? t8('This record has not been saved yet.') : undef,
2075 call => [ 'kivi.Order.follow_up_window' ],
2076 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
2077 only_if => $::auth->assert('productivity', 1),
2079 ], # end of combobox "more"
2085 my ($self, $doc_ref, $params) = @_;
2087 my $order = $self->order;
2090 my $print_form = Form->new('');
2091 $print_form->{type} = $order->type;
2092 $print_form->{formname} = $params->{formname} || $order->type;
2093 $print_form->{format} = $params->{format} || 'pdf';
2094 $print_form->{media} = $params->{media} || 'file';
2095 $print_form->{groupitems} = $params->{groupitems};
2096 $print_form->{printer_id} = $params->{printer_id};
2097 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
2099 $order->language($params->{language});
2100 $order->flatten_to_form($print_form, format_amounts => 1);
2104 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
2105 $template_ext = 'odt';
2106 $template_type = 'OpenDocument';
2107 } elsif ($print_form->{format} =~ m{html}i) {
2108 $template_ext = 'html';
2109 $template_type = 'HTML';
2112 # search for the template
2113 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
2114 name => $print_form->{formname},
2115 extension => $template_ext,
2116 email => $print_form->{media} eq 'email',
2117 language => $params->{language},
2118 printer_id => $print_form->{printer_id},
2121 if (!defined $template_file) {
2122 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);
2125 return @errors if scalar @errors;
2127 $print_form->throw_on_error(sub {
2129 $print_form->prepare_for_printing;
2131 $$doc_ref = SL::Helper::CreatePDF->create_pdf(
2132 format => $print_form->{format},
2133 template_type => $template_type,
2134 template => $template_file,
2135 variables => $print_form,
2136 variable_content_types => {
2137 longdescription => 'html',
2138 partnotes => 'html',
2140 $::form->get_variable_content_types_for_cvars,
2144 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
2150 sub get_files_for_email_dialog {
2153 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
2155 return %files if !$::instance_conf->get_doc_storage;
2157 if ($self->order->id) {
2158 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
2159 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
2160 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
2161 $files{project_files} = [ SL::File->get_all( object_id => $self->order->globalproject_id, object_type => 'project', file_type => 'attachment') ];
2165 uniq_by { $_->{id} }
2167 +{ id => $_->part->id,
2168 partnumber => $_->part->partnumber }
2169 } @{$self->order->items_sorted};
2171 foreach my $part (@parts) {
2172 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
2173 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
2176 foreach my $key (keys %files) {
2177 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
2183 sub make_periodic_invoices_config_from_yaml {
2184 my ($yaml_config) = @_;
2186 return if !$yaml_config;
2187 my $attr = SL::YAML::Load($yaml_config);
2188 return if 'HASH' ne ref $attr;
2189 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
2193 sub get_periodic_invoices_status {
2194 my ($self, $config) = @_;
2196 return if $self->type ne sales_order_type();
2197 return t8('not configured') if !$config;
2199 my $active = ('HASH' eq ref $config) ? $config->{active}
2200 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
2201 : die "Cannot get status of periodic invoices config";
2203 return $active ? t8('active') : t8('inactive');
2207 my ($self, $action) = @_;
2209 return '' if none { lc($action)} qw(add edit);
2212 # $::locale->text("Add Sales Order");
2213 # $::locale->text("Add Purchase Order");
2214 # $::locale->text("Add Quotation");
2215 # $::locale->text("Add Request for Quotation");
2216 # $::locale->text("Edit Sales Order");
2217 # $::locale->text("Edit Purchase Order");
2218 # $::locale->text("Edit Quotation");
2219 # $::locale->text("Edit Request for Quotation");
2221 $action = ucfirst(lc($action));
2222 return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order")
2223 : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order")
2224 : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation")
2225 : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
2229 sub get_item_cvpartnumber {
2230 my ($self, $item) = @_;
2232 return if !$self->search_cvpartnumber;
2233 return if !$self->order->customervendor;
2235 if ($self->cv eq 'vendor') {
2236 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
2237 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
2238 } elsif ($self->cv eq 'customer') {
2239 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
2240 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
2244 sub get_part_texts {
2245 my ($part_or_id, $language_or_id, %defaults) = @_;
2247 my $part = ref($part_or_id) ? $part_or_id : SL::DB::Part->load_cached($part_or_id);
2248 my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id;
2250 description => $defaults{description} // $part->description,
2251 longdescription => $defaults{longdescription} // $part->notes,
2254 return $texts unless $language_id;
2256 my $translation = SL::DB::Manager::Translation->get_first(
2258 parts_id => $part->id,
2259 language_id => $language_id,
2262 $texts->{description} = $translation->translation if $translation && $translation->translation;
2263 $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription;
2268 sub sales_order_type {
2272 sub purchase_order_type {
2276 sub sales_quotation_type {
2280 sub request_quotation_type {
2281 'request_quotation';
2285 return $_[0]->type eq sales_order_type() ? 'ordnumber'
2286 : $_[0]->type eq purchase_order_type() ? 'ordnumber'
2287 : $_[0]->type eq sales_quotation_type() ? 'quonumber'
2288 : $_[0]->type eq request_quotation_type() ? 'quonumber'
2292 sub save_and_redirect_to {
2293 my ($self, %params) = @_;
2295 my $errors = $self->save();
2297 if (scalar @{ $errors }) {
2298 $self->js->flash('error', $_) foreach @{ $errors };
2299 return $self->js->render();
2302 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
2303 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
2304 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
2305 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
2307 flash_later('info', $text);
2309 $self->redirect_to(%params, id => $self->order->id);
2313 my ($self, $addition) = @_;
2315 my $number_type = $self->order->type =~ m{order} ? 'ordnumber' : 'quonumber';
2316 my $snumbers = $number_type . '_' . $self->order->$number_type;
2318 SL::DB::History->new(
2319 trans_id => $self->order->id,
2320 employee_id => SL::DB::Manager::Employee->current->id,
2321 what_done => $self->order->type,
2322 snumbers => $snumbers,
2323 addition => $addition,
2327 sub store_doc_to_webdav_and_filemanagement {
2328 my ($self, $content, $filename) = @_;
2330 my $order = $self->order;
2333 # copy file to webdav folder
2334 if ($order->number && $::instance_conf->get_webdav_documents) {
2335 my $webdav = SL::Webdav->new(
2336 type => $order->type,
2337 number => $order->number,
2339 my $webdav_file = SL::Webdav::File->new(
2341 filename => $filename,
2344 $webdav_file->store(data => \$content);
2347 push @errors, t8('Storing the document to the WebDAV folder failed: #1', $@);
2350 if ($order->id && $::instance_conf->get_doc_storage) {
2352 SL::File->save(object_id => $order->id,
2353 object_type => $order->type,
2354 mime_type => SL::MIME->mime_type_from_ext($filename),
2355 source => 'created',
2356 file_type => 'document',
2357 file_name => $filename,
2358 file_contents => $content);
2361 push @errors, t8('Storing the document in the storage backend failed: #1', $@);
2368 sub link_requirement_specs_linking_to_created_from_objects {
2369 my ($self, @converted_from_oe_ids) = @_;
2371 return unless @converted_from_oe_ids;
2373 my $rs_orders = SL::DB::Manager::RequirementSpecOrder->get_all(where => [ order_id => \@converted_from_oe_ids ]);
2374 foreach my $rs_order (@{ $rs_orders }) {
2375 SL::DB::RequirementSpecOrder->new(
2376 order_id => $self->order->id,
2377 requirement_spec_id => $rs_order->requirement_spec_id,
2378 version_id => $rs_order->version_id,
2383 sub set_project_in_linked_requirement_specs {
2386 my $rs_orders = SL::DB::Manager::RequirementSpecOrder->get_all(where => [ order_id => $self->order->id ]);
2387 foreach my $rs_order (@{ $rs_orders }) {
2388 next if $rs_order->requirement_spec->project_id == $self->order->globalproject_id;
2390 $rs_order->requirement_spec->update_attributes(project_id => $self->order->globalproject_id);
2402 SL::Controller::Order - controller for orders
2406 This is a new form to enter orders, completely rewritten with the use
2407 of controller and java script techniques.
2409 The aim is to provide the user a better experience and a faster workflow. Also
2410 the code should be more readable, more reliable and better to maintain.
2418 One input row, so that input happens every time at the same place.
2422 Use of pickers where possible.
2426 Possibility to enter more than one item at once.
2430 Item list in a scrollable area, so that the workflow buttons stay at
2435 Reordering item rows with drag and drop is possible. Sorting item rows is
2436 possible (by partnumber, description, qty, sellprice and discount for now).
2440 No C<update> is necessary. All entries and calculations are managed
2441 with ajax-calls and the page only reloads on C<save>.
2445 User can see changes immediately, because of the use of java script
2456 =item * C<SL/Controller/Order.pm>
2460 =item * C<template/webpages/order/form.html>
2464 =item * C<template/webpages/order/tabs/basic_data.html>
2466 Main tab for basic_data.
2468 This is the only tab here for now. "linked records" and "webdav" tabs are
2469 reused from generic code.
2473 =item * C<template/webpages/order/tabs/_business_info_row.html>
2475 For displaying information on business type
2477 =item * C<template/webpages/order/tabs/_item_input.html>
2479 The input line for items
2481 =item * C<template/webpages/order/tabs/_row.html>
2483 One row for already entered items
2485 =item * C<template/webpages/order/tabs/_tax_row.html>
2487 Displaying tax information
2489 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
2491 Dialog for selecting price and discount sources
2495 =item * C<js/kivi.Order.js>
2497 java script functions
2507 =item * price sources: little symbols showing better price / better discount
2509 =item * select units in input row?
2511 =item * check for direct delivery (workflow sales order -> purchase order)
2513 =item * access rights
2515 =item * display weights
2519 =item * optional client/user behaviour
2521 (transactions has to be set - department has to be set -
2522 force project if enabled in client config)
2526 =head1 KNOWN BUGS AND CAVEATS
2532 Customer discount is not displayed as a valid discount in price source popup
2533 (this might be a bug in price sources)
2535 (I cannot reproduce this (Bernd))
2539 No indication that <shift>-up/down expands/collapses second row.
2543 Inline creation of parts is not currently supported
2547 Table header is not sticky in the scrolling area.
2551 Sorting does not include C<position>, neither does reordering.
2553 This behavior was implemented intentionally. But we can discuss, which behavior
2554 should be implemented.
2558 =head1 To discuss / Nice to have
2564 How to expand/collapse second row. Now it can be done clicking the icon or
2569 Possibility to select PriceSources in input row?
2573 This controller uses a (changed) copy of the template for the PriceSource
2574 dialog. Maybe there could be used one code source.
2578 Rounding-differences between this controller (PriceTaxCalculator) and the old
2579 form. This is not only a problem here, but also in all parts using the PTC.
2580 There exists a ticket and a patch. This patch should be testet.
2584 An indicator, if the actual inputs are saved (like in an
2585 editor or on text processing application).
2589 A warning when leaving the page without saveing unchanged inputs.
2596 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>