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, $formname);
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 => $self->type,
484 file_type => 'document',
485 print_variant => $::form->{formname});
488 if ($::form->{attachment_policy} ne 'no_file' && !($::form->{attachment_policy} eq 'old_file' && $attfile)) {
490 my @errors = $self->generate_doc(\$doc, {media => $::form->{media},
491 format => $::form->{print_options}->{format},
492 formname => $::form->{print_options}->{formname},
493 language => $self->order->language,
494 printer_id => $::form->{print_options}->{printer_id},
495 groupitems => $::form->{print_options}->{groupitems}});
496 if (scalar @errors) {
497 return $self->js->flash('error', t8('Generating the document failed: #1', $errors[0]))->render($self);
500 my @warnings = $self->store_doc_to_webdav_and_filemanagement($doc, $::form->{attachment_filename}, $::form->{formname});
501 if (scalar @warnings) {
502 flash_later('warning', $_) for @warnings;
505 my $sfile = SL::SessionFile::Random->new(mode => "w");
506 $sfile->fh->print($doc);
509 $::form->{tmpfile} = $sfile->file_name;
510 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
513 $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a linked record to the mail
514 $::form->send_email(\%::myconfig, $::form->{print_options}->{format});
517 my $intnotes = $self->order->intnotes;
518 $intnotes .= "\n\n" if $self->order->intnotes;
519 $intnotes .= t8('[email]') . "\n";
520 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
521 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
522 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
523 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
524 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
525 $intnotes .= t8('Message') . ": " . $::form->{message};
527 $self->order->update_attributes(intnotes => $intnotes);
529 $self->save_history('MAILED');
531 flash_later('info', t8('The email has been sent.'));
533 my @redirect_params = (
536 id => $self->order->id,
539 $self->redirect_to(@redirect_params);
542 # open the periodic invoices config dialog
544 # If there are values in the form (i.e. dialog was opened before),
545 # then use this values. Create new ones, else.
546 sub action_show_periodic_invoices_config_dialog {
549 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
550 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
551 $config ||= SL::DB::PeriodicInvoicesConfig->new(periodicity => 'm',
552 order_value_periodicity => 'p', # = same as periodicity
553 start_date_as_date => $::form->{transdate_as_date} || $::form->current_date,
554 extend_automatically_by => 12,
556 email_subject => GenericTranslations->get(
557 language_id => $::form->{language_id},
558 translation_type =>"preset_text_periodic_invoices_email_subject"),
559 email_body => GenericTranslations->get(
560 language_id => $::form->{language_id},
561 translation_type =>"preset_text_periodic_invoices_email_body"),
563 $config->periodicity('m') if none { $_ eq $config->periodicity } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
564 $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
566 $::form->get_lists(printers => "ALL_PRINTERS",
567 charts => { key => 'ALL_CHARTS',
568 transdate => 'current_date' });
570 $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
572 if ($::form->{customer_id}) {
573 $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
574 my $customer_object = SL::DB::Manager::Customer->find_by(id => $::form->{customer_id});
575 $::form->{postal_invoice} = $customer_object->postal_invoice;
576 $::form->{email_recipient_invoice_address} = $::form->{postal_invoice} ? '' : $customer_object->invoice_mail;
577 $config->send_email(0) if $::form->{postal_invoice};
580 $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
582 popup_js_close_function => 'kivi.Order.close_periodic_invoices_config_dialog()',
583 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
588 # assign the values of the periodic invoices config dialog
589 # as yaml in the hidden tag and set the status.
590 sub action_assign_periodic_invoices_config {
593 $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
595 my $config = { active => $::form->{active} ? 1 : 0,
596 terminated => $::form->{terminated} ? 1 : 0,
597 direct_debit => $::form->{direct_debit} ? 1 : 0,
598 periodicity => (any { $_ eq $::form->{periodicity} } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES) ? $::form->{periodicity} : 'm',
599 order_value_periodicity => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
600 start_date_as_date => $::form->{start_date_as_date},
601 end_date_as_date => $::form->{end_date_as_date},
602 first_billing_date_as_date => $::form->{first_billing_date_as_date},
603 print => $::form->{print} ? 1 : 0,
604 printer_id => $::form->{print} ? $::form->{printer_id} * 1 : undef,
605 copies => $::form->{copies} * 1 ? $::form->{copies} : 1,
606 extend_automatically_by => $::form->{extend_automatically_by} * 1 || undef,
607 ar_chart_id => $::form->{ar_chart_id} * 1,
608 send_email => $::form->{send_email} ? 1 : 0,
609 email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
610 email_recipient_address => $::form->{email_recipient_address},
611 email_sender => $::form->{email_sender},
612 email_subject => $::form->{email_subject},
613 email_body => $::form->{email_body},
616 my $periodic_invoices_config = SL::YAML::Dump($config);
618 my $status = $self->get_periodic_invoices_status($config);
621 ->remove('#order_periodic_invoices_config')
622 ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
623 ->run('kivi.Order.close_periodic_invoices_config_dialog')
624 ->html('#periodic_invoices_status', $status)
625 ->flash('info', t8('The periodic invoices config has been assigned.'))
629 sub action_get_has_active_periodic_invoices {
632 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
633 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
635 my $has_active_periodic_invoices =
636 $self->type eq sales_order_type()
639 && (!$config->end_date || ($config->end_date > DateTime->today_local))
640 && $config->get_previous_billed_period_start_date;
642 $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
645 # save the order and redirect to the frontend subroutine for a new
647 sub action_save_and_delivery_order {
650 $self->save_and_redirect_to(
651 controller => 'oe.pl',
652 action => 'oe_delivery_order_from_order',
656 # save the order and redirect to the frontend subroutine for a new
658 sub action_save_and_invoice {
661 $self->save_and_redirect_to(
662 controller => 'oe.pl',
663 action => 'oe_invoice_from_order',
667 # workflow from sales order to sales quotation
668 sub action_sales_quotation {
669 $_[0]->workflow_sales_or_request_for_quotation();
672 # workflow from sales order to sales quotation
673 sub action_request_for_quotation {
674 $_[0]->workflow_sales_or_request_for_quotation();
677 # workflow from sales quotation to sales order
678 sub action_sales_order {
679 $_[0]->workflow_sales_or_purchase_order();
682 # workflow from rfq to purchase order
683 sub action_purchase_order {
684 $_[0]->workflow_sales_or_purchase_order();
687 # workflow from purchase order to ap transaction
688 sub action_save_and_ap_transaction {
691 $self->save_and_redirect_to(
692 controller => 'ap.pl',
693 action => 'add_from_purchase_order',
697 # set form elements in respect to a changed customer or vendor
699 # This action is called on an change of the customer/vendor picker.
700 sub action_customer_vendor_changed {
703 setup_order_from_cv($self->order);
706 my $cv_method = $self->cv;
708 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
709 $self->js->show('#cp_row');
711 $self->js->hide('#cp_row');
714 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
715 $self->js->show('#shipto_selection');
717 $self->js->hide('#shipto_selection');
720 if ($cv_method eq 'customer') {
721 my $show_hide = scalar @{ $self->order->customer->additional_billing_addresses } > 0 ? 'show' : 'hide';
722 $self->js->$show_hide('#billing_address_row');
725 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
728 ->replaceWith('#order_cp_id', $self->build_contact_select)
729 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
730 ->replaceWith('#shipto_inputs ', $self->build_shipto_inputs)
731 ->replaceWith('#order_billing_address_id', $self->build_billing_address_select)
732 ->replaceWith('#business_info_row', $self->build_business_info_row)
733 ->val( '#order_taxzone_id', $self->order->taxzone_id)
734 ->val( '#order_taxincluded', $self->order->taxincluded)
735 ->val( '#order_currency_id', $self->order->currency_id)
736 ->val( '#order_payment_id', $self->order->payment_id)
737 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
738 ->val( '#order_intnotes', $self->order->intnotes)
739 ->val( '#order_language_id', $self->order->$cv_method->language_id)
740 ->focus( '#order_' . $self->cv . '_id')
741 ->run('kivi.Order.update_exchangerate');
743 $self->js_redisplay_amounts_and_taxes;
744 $self->js_redisplay_cvpartnumbers;
748 # open the dialog for customer/vendor details
749 sub action_show_customer_vendor_details_dialog {
752 my $is_customer = 'customer' eq $::form->{vc};
755 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
757 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
760 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
761 $details{discount_as_percent} = $cv->discount_as_percent;
762 $details{creditlimt} = $cv->creditlimit_as_number;
763 $details{business} = $cv->business->description if $cv->business;
764 $details{language} = $cv->language_obj->description if $cv->language_obj;
765 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
766 $details{payment_terms} = $cv->payment->description if $cv->payment;
767 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
769 foreach my $entry (@{ $cv->additional_billing_addresses }) {
770 push @{ $details{ADDITIONAL_BILLING_ADDRESSES} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
772 foreach my $entry (@{ $cv->shipto }) {
773 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
775 foreach my $entry (@{ $cv->contacts }) {
776 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
779 $_[0]->render('common/show_vc_details', { layout => 0 },
780 is_customer => $is_customer,
785 # called if a unit in an existing item row is changed
786 sub action_unit_changed {
789 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
790 my $item = $self->order->items_sorted->[$idx];
792 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
793 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
798 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
799 $self->js_redisplay_line_values;
800 $self->js_redisplay_amounts_and_taxes;
804 # add an item row for a new item entered in the input row
805 sub action_add_item {
808 delete $::form->{add_item}->{create_part_type};
810 my $form_attr = $::form->{add_item};
812 return unless $form_attr->{parts_id};
814 my $item = new_item($self->order, $form_attr);
816 $self->order->add_items($item);
820 $self->get_item_cvpartnumber($item);
822 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
823 my $row_as_html = $self->p->render('order/tabs/_row',
829 if ($::form->{insert_before_item_id}) {
831 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
834 ->append('#row_table_id', $row_as_html);
837 if ( $item->part->is_assortment ) {
838 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
839 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
840 my $attr = { parts_id => $assortment_item->parts_id,
841 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
842 unit => $assortment_item->unit,
843 description => $assortment_item->part->description,
845 my $item = new_item($self->order, $attr);
847 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
848 $item->discount(1) unless $assortment_item->charge;
850 $self->order->add_items( $item );
852 $self->get_item_cvpartnumber($item);
853 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
854 my $row_as_html = $self->p->render('order/tabs/_row',
859 if ($::form->{insert_before_item_id}) {
861 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
864 ->append('#row_table_id', $row_as_html);
870 ->val('.add_item_input', '')
871 ->run('kivi.Order.init_row_handlers')
872 ->run('kivi.Order.renumber_positions')
873 ->focus('#add_item_parts_id_name');
875 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
877 $self->js_redisplay_amounts_and_taxes;
881 # add item rows for multiple items at once
882 sub action_add_multi_items {
885 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
886 return $self->js->render() unless scalar @form_attr;
889 foreach my $attr (@form_attr) {
890 my $item = new_item($self->order, $attr);
892 if ( $item->part->is_assortment ) {
893 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
894 my $attr = { parts_id => $assortment_item->parts_id,
895 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
896 unit => $assortment_item->unit,
897 description => $assortment_item->part->description,
899 my $item = new_item($self->order, $attr);
901 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
902 $item->discount(1) unless $assortment_item->charge;
907 $self->order->add_items(@items);
911 foreach my $item (@items) {
912 $self->get_item_cvpartnumber($item);
913 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
914 my $row_as_html = $self->p->render('order/tabs/_row',
920 if ($::form->{insert_before_item_id}) {
922 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
925 ->append('#row_table_id', $row_as_html);
930 ->run('kivi.Part.close_picker_dialogs')
931 ->run('kivi.Order.init_row_handlers')
932 ->run('kivi.Order.renumber_positions')
933 ->focus('#add_item_parts_id_name');
935 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
937 $self->js_redisplay_amounts_and_taxes;
941 # recalculate all linetotals, amounts and taxes and redisplay them
942 sub action_recalc_amounts_and_taxes {
947 $self->js_redisplay_line_values;
948 $self->js_redisplay_amounts_and_taxes;
952 sub action_update_exchangerate {
956 is_standard => $self->order->currency_id == $::instance_conf->get_currency_id,
957 currency_name => $self->order->currency->name,
958 exchangerate => $self->order->daily_exchangerate_as_null_number,
961 $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
964 # redisplay item rows if they are sorted by an attribute
965 sub action_reorder_items {
969 partnumber => sub { $_[0]->part->partnumber },
970 description => sub { $_[0]->description },
971 qty => sub { $_[0]->qty },
972 sellprice => sub { $_[0]->sellprice },
973 discount => sub { $_[0]->discount },
974 cvpartnumber => sub { $_[0]->{cvpartnumber} },
977 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
979 my $method = $sort_keys{$::form->{order_by}};
980 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
981 if ($::form->{sort_dir}) {
982 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
983 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
985 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
988 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
989 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
991 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
995 ->run('kivi.Order.redisplay_items', \@to_sort)
999 # show the popup to choose a price/discount source
1000 sub action_price_popup {
1003 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
1004 my $item = $self->order->items_sorted->[$idx];
1006 $self->render_price_dialog($item);
1009 # save the order in a session variable and redirect to the part controller
1010 sub action_create_part {
1013 my $previousform = $::auth->save_form_in_session(non_scalars => 1);
1015 my $callback = $self->url_for(
1016 action => 'return_from_create_part',
1017 type => $self->type, # type is needed for check_auth on return
1018 previousform => $previousform,
1021 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.'));
1023 my @redirect_params = (
1024 controller => 'Part',
1026 part_type => $::form->{add_item}->{create_part_type},
1027 callback => $callback,
1031 $self->redirect_to(@redirect_params);
1034 sub action_return_from_create_part {
1037 $self->{created_part} = SL::DB::Part->new(id => delete $::form->{new_parts_id})->load if $::form->{new_parts_id};
1039 $::auth->restore_form_from_session(delete $::form->{previousform});
1041 # set item ids to new fake id, to identify them as new items
1042 foreach my $item (@{$self->order->items_sorted}) {
1043 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1047 $self->get_unalterable_data();
1048 $self->pre_render();
1050 # trigger rendering values for second row/longdescription as hidden,
1051 # because they are loaded only on demand. So we need to keep the values
1053 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1054 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
1058 title => $self->get_title_for('edit'),
1059 %{$self->{template_args}}
1064 # load the second row for one or more items
1066 # This action gets the html code for all items second rows by rendering a template for
1067 # the second row and sets the html code via client js.
1068 sub action_load_second_rows {
1071 $self->recalc() if $self->order->is_sales; # for margin calculation
1073 foreach my $item_id (@{ $::form->{item_ids} }) {
1074 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1075 my $item = $self->order->items_sorted->[$idx];
1077 $self->js_load_second_row($item, $item_id, 0);
1080 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
1082 $self->js->render();
1085 # update description, notes and sellprice from master data
1086 sub action_update_row_from_master_data {
1089 foreach my $item_id (@{ $::form->{item_ids} }) {
1090 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1091 my $item = $self->order->items_sorted->[$idx];
1092 my $texts = get_part_texts($item->part, $self->order->language_id);
1094 $item->description($texts->{description});
1095 $item->longdescription($texts->{longdescription});
1097 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1100 if ($item->part->is_assortment) {
1101 # add assortment items with price 0, as the components carry the price
1102 $price_src = $price_source->price_from_source("");
1103 $price_src->price(0);
1105 $price_src = $price_source->best_price
1106 ? $price_source->best_price
1107 : $price_source->price_from_source("");
1108 $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate;
1109 $price_src->price(0) if !$price_source->best_price;
1113 $item->sellprice($price_src->price);
1114 $item->active_price_source($price_src);
1117 ->run('kivi.Order.update_sellprice', $item_id, $item->sellprice_as_number)
1118 ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
1119 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
1120 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
1122 if ($self->search_cvpartnumber) {
1123 $self->get_item_cvpartnumber($item);
1124 $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
1129 $self->js_redisplay_line_values;
1130 $self->js_redisplay_amounts_and_taxes;
1132 $self->js->render();
1135 sub js_load_second_row {
1136 my ($self, $item, $item_id, $do_parse) = @_;
1139 # Parse values from form (they are formated while rendering (template)).
1140 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1141 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1142 foreach my $var (@{ $item->cvars_by_config }) {
1143 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1145 $item->parse_custom_variable_values;
1148 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1151 ->html('#second_row_' . $item_id, $row_as_html)
1152 ->data('#second_row_' . $item_id, 'loaded', 1);
1155 sub js_redisplay_line_values {
1158 my $is_sales = $self->order->is_sales;
1160 # sales orders with margins
1165 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1166 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1167 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1168 ]} @{ $self->order->items_sorted };
1172 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1173 ]} @{ $self->order->items_sorted };
1177 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1180 sub js_redisplay_amounts_and_taxes {
1183 if (scalar @{ $self->{taxes} }) {
1184 $self->js->show('#taxincluded_row_id');
1186 $self->js->hide('#taxincluded_row_id');
1189 if ($self->order->taxincluded) {
1190 $self->js->hide('#subtotal_row_id');
1192 $self->js->show('#subtotal_row_id');
1195 if ($self->order->is_sales) {
1196 my $is_neg = $self->order->marge_total < 0;
1198 ->html('#marge_total_id', $::form->format_amount(\%::myconfig, $self->order->marge_total, 2))
1199 ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
1200 ->action_if( $is_neg, 'addClass', '#marge_total_id', 'plus0')
1201 ->action_if( $is_neg, 'addClass', '#marge_percent_id', 'plus0')
1202 ->action_if( $is_neg, 'addClass', '#marge_percent_sign_id', 'plus0')
1203 ->action_if(!$is_neg, 'removeClass', '#marge_total_id', 'plus0')
1204 ->action_if(!$is_neg, 'removeClass', '#marge_percent_id', 'plus0')
1205 ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
1209 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
1210 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
1211 ->remove('.tax_row')
1212 ->insertBefore($self->build_tax_rows, '#amount_row_id');
1215 sub js_redisplay_cvpartnumbers {
1218 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1220 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1223 ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
1226 sub js_reset_order_and_item_ids_after_save {
1230 ->val('#id', $self->order->id)
1231 ->val('#converted_from_oe_id', '')
1232 ->val('#order_' . $self->nr_key(), $self->order->number);
1235 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1236 next if !$self->order->items_sorted->[$idx]->id;
1237 next if $form_item_id !~ m{^new};
1239 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1240 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1241 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1245 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1252 sub init_valid_types {
1253 [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
1259 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1260 die "Not a valid type for order";
1263 $self->type($::form->{type});
1269 my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer'
1270 : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
1271 : die "Not a valid type for order";
1276 sub init_search_cvpartnumber {
1279 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1280 my $search_cvpartnumber;
1281 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1282 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1284 return $search_cvpartnumber;
1287 sub init_show_update_button {
1290 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1301 sub init_all_price_factors {
1302 SL::DB::Manager::PriceFactor->get_all;
1305 sub init_part_picker_classification_ids {
1307 my $attribute = 'used_for_' . ($self->type =~ m{sales} ? 'sale' : 'purchase');
1309 return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => [ $attribute => 1 ]) } ];
1315 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
1317 my $right = $right_for->{ $self->type };
1318 $right ||= 'DOES_NOT_EXIST';
1320 $::auth->assert($right);
1323 # build the selection box for contacts
1325 # Needed, if customer/vendor changed.
1326 sub build_contact_select {
1329 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1330 value_key => 'cp_id',
1331 title_key => 'full_name_dep',
1332 default => $self->order->cp_id,
1334 style => 'width: 300px',
1338 # build the selection box for the additional billing address
1340 # Needed, if customer/vendor changed.
1341 sub build_billing_address_select {
1344 select_tag('order.billing_address_id',
1345 [ {displayable_id => '', id => ''}, $self->order->{$self->cv}->additional_billing_addresses ],
1347 title_key => 'displayable_id',
1348 default => $self->order->billing_address_id,
1350 style => 'width: 300px',
1354 # build the selection box for shiptos
1356 # Needed, if customer/vendor changed.
1357 sub build_shipto_select {
1360 select_tag('order.shipto_id',
1361 [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
1362 value_key => 'shipto_id',
1363 title_key => 'displayable_id',
1364 default => $self->order->shipto_id,
1366 style => 'width: 300px',
1370 # build the inputs for the cusom shipto dialog
1372 # Needed, if customer/vendor changed.
1373 sub build_shipto_inputs {
1376 my $content = $self->p->render('common/_ship_to_dialog',
1377 vc_obj => $self->order->customervendor,
1378 cs_obj => $self->order->custom_shipto,
1379 cvars => $self->order->custom_shipto->cvars_by_config,
1380 id_selector => '#order_shipto_id');
1382 div_tag($content, id => 'shipto_inputs');
1385 # render the info line for business
1387 # Needed, if customer/vendor changed.
1388 sub build_business_info_row
1390 $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
1393 # build the rows for displaying taxes
1395 # Called if amounts where recalculated and redisplayed.
1396 sub build_tax_rows {
1400 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
1401 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
1403 return $rows_as_html;
1407 sub render_price_dialog {
1408 my ($self, $record_item) = @_;
1410 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1414 'kivi.io.price_chooser_dialog',
1415 t8('Available Prices'),
1416 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1421 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1422 # $self->js->show('#dialog_flash_error');
1431 return if !$::form->{id};
1433 $self->order(SL::DB::Order->new(id => $::form->{id})->load);
1435 # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
1436 # You need a custom shipto object to call cvars_by_config to get the cvars.
1437 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
1439 return $self->order;
1442 # load or create a new order object
1444 # And assign changes from the form to this object.
1445 # If the order is loaded from db, check if items are deleted in the form,
1446 # remove them form the object and collect them for removing from db on saving.
1447 # Then create/update items from form (via make_item) and add them.
1451 # add_items adds items to an order with no items for saving, but they cannot
1452 # be retrieved via items until the order is saved. Adding empty items to new
1453 # order here solves this problem.
1455 $order = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1456 $order ||= SL::DB::Order->new(orderitems => [],
1457 quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())),
1458 currency_id => $::instance_conf->get_currency_id(),);
1460 my $cv_id_method = $self->cv . '_id';
1461 if (!$::form->{id} && $::form->{$cv_id_method}) {
1462 $order->$cv_id_method($::form->{$cv_id_method});
1463 setup_order_from_cv($order);
1466 my $form_orderitems = delete $::form->{order}->{orderitems};
1467 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1469 $order->assign_attributes(%{$::form->{order}});
1471 $self->setup_custom_shipto_from_form($order, $::form);
1473 if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) {
1474 my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
1475 $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
1478 # remove deleted items
1479 $self->item_ids_to_delete([]);
1480 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1481 my $item = $order->orderitems->[$idx];
1482 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1483 splice @{$order->orderitems}, $idx, 1;
1484 push @{$self->item_ids_to_delete}, $item->id;
1490 foreach my $form_attr (@{$form_orderitems}) {
1491 my $item = make_item($order, $form_attr);
1492 $item->position($pos);
1496 $order->add_items(grep {!$_->id} @items);
1501 # create or update items from form
1503 # Make item objects from form values. For items already existing read from db.
1504 # Create a new item else. And assign attributes.
1506 my ($record, $attr) = @_;
1509 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1511 my $is_new = !$item;
1513 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1514 # they cannot be retrieved via custom_variables until the order/orderitem is
1515 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1516 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1518 $item->assign_attributes(%$attr);
1521 my $texts = get_part_texts($item->part, $record->language_id);
1522 $item->longdescription($texts->{longdescription}) if !defined $attr->{longdescription};
1523 $item->project_id($record->globalproject_id) if !defined $attr->{project_id};
1524 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number};
1532 # This is used to add one item
1534 my ($record, $attr) = @_;
1536 my $item = SL::DB::OrderItem->new;
1538 # Remove attributes where the user left or set the inputs empty.
1539 # So these attributes will be undefined and we can distinguish them
1540 # from zero later on.
1541 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1542 delete $attr->{$_} if $attr->{$_} eq '';
1545 $item->assign_attributes(%$attr);
1547 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1548 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1550 $item->unit($part->unit) if !$item->unit;
1553 if ( $part->is_assortment ) {
1554 # add assortment items with price 0, as the components carry the price
1555 $price_src = $price_source->price_from_source("");
1556 $price_src->price(0);
1557 } elsif (defined $item->sellprice) {
1558 $price_src = $price_source->price_from_source("");
1559 $price_src->price($item->sellprice);
1561 $price_src = $price_source->best_price
1562 ? $price_source->best_price
1563 : $price_source->price_from_source("");
1564 $price_src->price($::form->round_amount($price_src->price / $record->exchangerate, 5)) if $record->exchangerate;
1565 $price_src->price(0) if !$price_source->best_price;
1569 if (defined $item->discount) {
1570 $discount_src = $price_source->discount_from_source("");
1571 $discount_src->discount($item->discount);
1573 $discount_src = $price_source->best_discount
1574 ? $price_source->best_discount
1575 : $price_source->discount_from_source("");
1576 $discount_src->discount(0) if !$price_source->best_discount;
1580 $new_attr{part} = $part;
1581 $new_attr{description} = $part->description if ! $item->description;
1582 $new_attr{qty} = 1.0 if ! $item->qty;
1583 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1584 $new_attr{sellprice} = $price_src->price;
1585 $new_attr{discount} = $discount_src->discount;
1586 $new_attr{active_price_source} = $price_src;
1587 $new_attr{active_discount_source} = $discount_src;
1588 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1589 $new_attr{project_id} = $record->globalproject_id;
1590 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1592 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1593 # they cannot be retrieved via custom_variables until the order/orderitem is
1594 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1595 $new_attr{custom_variables} = [];
1597 my $texts = get_part_texts($part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription});
1599 $item->assign_attributes(%new_attr, %{ $texts });
1604 sub setup_order_from_cv {
1607 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id));
1609 $order->intnotes($order->customervendor->notes);
1611 return if !$order->is_sales;
1613 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1614 $order->taxincluded(defined($order->customer->taxincluded_checked)
1615 ? $order->customer->taxincluded_checked
1616 : $::myconfig{taxincluded_checked});
1618 my $address = $order->customer->default_billing_address;;
1619 $order->billing_address_id($address ? $address->id : undef);
1622 # setup custom shipto from form
1624 # The dialog returns form variables starting with 'shipto' and cvars starting
1625 # with 'shiptocvar_'.
1626 # Mark it to be deleted if a shipto from master data is selected
1627 # (i.e. order has a shipto).
1628 # Else, update or create a new custom shipto. If the fields are empty, it
1629 # will not be saved on save.
1630 sub setup_custom_shipto_from_form {
1631 my ($self, $order, $form) = @_;
1633 if ($order->shipto) {
1634 $self->is_custom_shipto_to_delete(1);
1636 my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1638 my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
1639 my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
1641 $custom_shipto->assign_attributes(%$shipto_attrs);
1642 $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
1646 # recalculate prices and taxes
1648 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1652 my %pat = $self->order->calculate_prices_and_taxes();
1654 $self->{taxes} = [];
1655 foreach my $tax_id (keys %{ $pat{taxes_by_tax_id} }) {
1656 my $netamount = sum0 map { $pat{amounts}->{$_}->{amount} } grep { $pat{amounts}->{$_}->{tax_id} == $tax_id } keys %{ $pat{amounts} };
1658 push(@{ $self->{taxes} }, { amount => $pat{taxes_by_tax_id}->{$tax_id},
1659 netamount => $netamount,
1660 tax => SL::DB::Tax->new(id => $tax_id)->load });
1662 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items_sorted}, @{$pat{items}};
1665 # get data for saving, printing, ..., that is not changed in the form
1667 # Only cvars for now.
1668 sub get_unalterable_data {
1671 foreach my $item (@{ $self->order->items }) {
1672 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1673 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1674 foreach my $var (@{ $item->cvars_by_config }) {
1675 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1677 $item->parse_custom_variable_values;
1683 # And remove related files in the spool directory
1688 my $db = $self->order->db;
1690 $db->with_transaction(
1692 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1693 $self->order->delete;
1694 my $spool = $::lx_office_conf{paths}->{spool};
1695 unlink map { "$spool/$_" } @spoolfiles if $spool;
1697 $self->save_history('DELETED');
1700 }) || push(@{$errors}, $db->error);
1707 # And delete items that are deleted in the form.
1712 my $db = $self->order->db;
1714 $db->with_transaction(sub {
1715 # delete custom shipto if it is to be deleted or if it is empty
1716 if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
1717 $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
1718 $self->order->custom_shipto(undef);
1721 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1722 $self->order->save(cascade => 1);
1725 if ($::form->{converted_from_oe_id}) {
1726 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1728 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1729 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1730 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
1731 $src->link_to_record($self->order);
1733 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1735 foreach (@{ $self->order->items_sorted }) {
1736 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1738 SL::DB::RecordLink->new(from_table => 'orderitems',
1739 from_id => $from_id,
1740 to_table => 'orderitems',
1747 $self->link_requirement_specs_linking_to_created_from_objects(@converted_from_oe_ids);
1750 $self->set_project_in_linked_requirement_specs if $self->order->globalproject_id;
1752 $self->save_history('SAVED');
1755 }) || push(@{$errors}, $db->error);
1760 sub workflow_sales_or_request_for_quotation {
1764 my $errors = $self->save();
1766 if (scalar @{ $errors }) {
1767 $self->js->flash('error', $_) for @{ $errors };
1768 return $self->js->render();
1771 my $destination_type = $::form->{type} eq sales_order_type() ? sales_quotation_type() : request_quotation_type();
1773 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1774 $self->{converted_from_oe_id} = delete $::form->{id};
1776 # set item ids to new fake id, to identify them as new items
1777 foreach my $item (@{$self->order->items_sorted}) {
1778 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1782 $::form->{type} = $destination_type;
1783 $self->type($self->init_type);
1784 $self->cv ($self->init_cv);
1788 $self->get_unalterable_data();
1789 $self->pre_render();
1791 # trigger rendering values for second row as hidden, because they
1792 # are loaded only on demand. So we need to keep the values from the
1794 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1798 title => $self->get_title_for('edit'),
1799 %{$self->{template_args}}
1803 sub workflow_sales_or_purchase_order {
1807 my $errors = $self->save();
1809 if (scalar @{ $errors }) {
1810 $self->js->flash('error', $_) foreach @{ $errors };
1811 return $self->js->render();
1814 my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type()
1815 : $::form->{type} eq request_quotation_type() ? purchase_order_type()
1816 : $::form->{type} eq purchase_order_type() ? sales_order_type()
1817 : $::form->{type} eq sales_order_type() ? purchase_order_type()
1820 # check for direct delivery
1821 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
1823 if ( $::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()
1824 && $::form->{use_shipto} && $self->order->shipto) {
1825 $custom_shipto = $self->order->shipto->clone('SL::DB::Order');
1828 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1829 $self->{converted_from_oe_id} = delete $::form->{id};
1831 # set item ids to new fake id, to identify them as new items
1832 foreach my $item (@{$self->order->items_sorted}) {
1833 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1836 if ($::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()) {
1837 if ($::form->{use_shipto}) {
1838 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
1840 # remove any custom shipto if not wanted
1841 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1846 $::form->{type} = $destination_type;
1847 $self->type($self->init_type);
1848 $self->cv ($self->init_cv);
1852 $self->get_unalterable_data();
1853 $self->pre_render();
1855 # trigger rendering values for second row as hidden, because they
1856 # are loaded only on demand. So we need to keep the values from the
1858 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1862 title => $self->get_title_for('edit'),
1863 %{$self->{template_args}}
1871 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1872 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
1873 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1874 $self->{all_languages} = SL::DB::Manager::Language->get_all_sorted();
1875 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1878 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1881 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1883 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1884 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1885 $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
1886 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1887 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1889 my $print_form = Form->new('');
1890 $print_form->{type} = $self->type;
1891 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1892 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1893 form => $print_form,
1894 options => {dialog_name_prefix => 'print_options.',
1898 no_opendocument => 0,
1902 foreach my $item (@{$self->order->orderitems}) {
1903 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1904 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1905 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1908 if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
1909 # Calculate shipped qtys here to prevent calling calculate for every item via the items method.
1910 # Do not use write_to_objects to prevent order->delivered to be set, because this should be
1911 # the value from db, which can be set manually or is set when linked delivery orders are saved.
1912 SL::Helper::ShippedQty->new->calculate($self->order)->write_to(\@{$self->order->items});
1915 if ($self->order->number && $::instance_conf->get_webdav) {
1916 my $webdav = SL::Webdav->new(
1917 type => $self->type,
1918 number => $self->order->number,
1920 my @all_objects = $webdav->get_all_objects;
1921 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1923 link => File::Spec->catfile($_->full_filedescriptor),
1927 if ( (any { $self->type eq $_ } (sales_quotation_type(), sales_order_type()))
1928 && $::instance_conf->get_transport_cost_reminder_article_number_id ) {
1929 $self->{template_args}->{transport_cost_reminder_article} = SL::DB::Part->new(id => $::instance_conf->get_transport_cost_reminder_article_number_id)->load;
1932 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1934 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.Validator kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
1935 edit_periodic_invoices_config calculate_qty follow_up show_history);
1936 $self->setup_edit_action_bar;
1939 sub setup_edit_action_bar {
1940 my ($self, %params) = @_;
1942 my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
1943 || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
1944 || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
1946 my @req_trans_cost_art = qw(kivi.Order.check_transport_cost_article_presence) x!!$::instance_conf->get_transport_cost_reminder_article_number_id;
1947 my @req_cusordnumber = qw(kivi.Order.check_cusordnumber_presence) x($self->type eq sales_order_type() && $::instance_conf->get_order_warn_no_cusordnumber);
1949 for my $bar ($::request->layout->get('actionbar')) {
1954 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1955 $::instance_conf->get_order_warn_no_deliverydate,
1957 checks => [ 'kivi.Order.check_save_active_periodic_invoices', ['kivi.validate_form','#order_form'],
1958 @req_trans_cost_art, @req_cusordnumber,
1963 call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1964 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
1965 @req_trans_cost_art, @req_cusordnumber,
1967 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1969 ], # end of combobox "Save"
1976 t8('Save and Quotation'),
1977 submit => [ '#order_form', { action => "Order/sales_quotation" } ],
1978 checks => [ @req_trans_cost_art, @req_cusordnumber ],
1979 only_if => (any { $self->type eq $_ } (sales_order_type())),
1983 submit => [ '#order_form', { action => "Order/request_for_quotation" } ],
1984 only_if => (any { $self->type eq $_ } (purchase_order_type())),
1987 t8('Save and Sales Order'),
1988 submit => [ '#order_form', { action => "Order/sales_order" } ],
1989 checks => [ @req_trans_cost_art ],
1990 only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
1993 t8('Save and Purchase Order'),
1994 call => [ 'kivi.Order.purchase_order_check_for_direct_delivery' ],
1995 checks => [ @req_trans_cost_art, @req_cusordnumber ],
1996 only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
1999 t8('Save and Delivery Order'),
2000 call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
2001 $::instance_conf->get_order_warn_no_deliverydate,
2003 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2004 @req_trans_cost_art, @req_cusordnumber,
2006 only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type()))
2009 t8('Save and Invoice'),
2010 call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
2011 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2012 @req_trans_cost_art, @req_cusordnumber,
2016 t8('Save and AP Transaction'),
2017 call => [ 'kivi.Order.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
2018 only_if => (any { $self->type eq $_ } (purchase_order_type()))
2021 ], # end of combobox "Workflow"
2028 t8('Save and preview PDF'),
2029 call => [ 'kivi.Order.save', 'preview_pdf', $::instance_conf->get_order_warn_duplicate_parts,
2030 $::instance_conf->get_order_warn_no_deliverydate,
2032 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2035 t8('Save and print'),
2036 call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts,
2037 $::instance_conf->get_order_warn_no_deliverydate,
2039 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2042 t8('Save and E-mail'),
2043 id => 'save_and_email_action',
2044 call => [ 'kivi.Order.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts,
2045 $::instance_conf->get_order_warn_no_deliverydate,
2047 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
2050 t8('Download attachments of all parts'),
2051 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
2052 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
2053 only_if => $::instance_conf->get_doc_storage,
2055 ], # end of combobox "Export"
2059 call => [ 'kivi.Order.delete_order' ],
2060 confirm => $::locale->text('Do you really want to delete this object?'),
2061 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
2062 only_if => $deletion_allowed,
2071 call => [ 'set_history_window', $self->order->id, 'id' ],
2072 disabled => !$self->order->id ? t8('This record has not been saved yet.') : undef,
2076 call => [ 'kivi.Order.follow_up_window' ],
2077 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
2078 only_if => $::auth->assert('productivity', 1),
2080 ], # end of combobox "more"
2086 my ($self, $doc_ref, $params) = @_;
2088 my $order = $self->order;
2091 my $print_form = Form->new('');
2092 $print_form->{type} = $order->type;
2093 $print_form->{formname} = $params->{formname} || $order->type;
2094 $print_form->{format} = $params->{format} || 'pdf';
2095 $print_form->{media} = $params->{media} || 'file';
2096 $print_form->{groupitems} = $params->{groupitems};
2097 $print_form->{printer_id} = $params->{printer_id};
2098 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
2100 $order->language($params->{language});
2101 $order->flatten_to_form($print_form, format_amounts => 1);
2105 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
2106 $template_ext = 'odt';
2107 $template_type = 'OpenDocument';
2108 } elsif ($print_form->{format} =~ m{html}i) {
2109 $template_ext = 'html';
2110 $template_type = 'HTML';
2113 # search for the template
2114 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
2115 name => $print_form->{formname},
2116 extension => $template_ext,
2117 email => $print_form->{media} eq 'email',
2118 language => $params->{language},
2119 printer_id => $print_form->{printer_id},
2122 if (!defined $template_file) {
2123 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);
2126 return @errors if scalar @errors;
2128 $print_form->throw_on_error(sub {
2130 $print_form->prepare_for_printing;
2132 $$doc_ref = SL::Helper::CreatePDF->create_pdf(
2133 format => $print_form->{format},
2134 template_type => $template_type,
2135 template => $template_file,
2136 variables => $print_form,
2137 variable_content_types => {
2138 longdescription => 'html',
2139 partnotes => 'html',
2141 $::form->get_variable_content_types_for_cvars,
2145 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
2151 sub get_files_for_email_dialog {
2154 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
2156 return %files if !$::instance_conf->get_doc_storage;
2158 if ($self->order->id) {
2159 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
2160 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
2161 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
2162 $files{project_files} = [ SL::File->get_all( object_id => $self->order->globalproject_id, object_type => 'project', file_type => 'attachment') ];
2166 uniq_by { $_->{id} }
2168 +{ id => $_->part->id,
2169 partnumber => $_->part->partnumber }
2170 } @{$self->order->items_sorted};
2172 foreach my $part (@parts) {
2173 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
2174 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
2177 foreach my $key (keys %files) {
2178 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
2184 sub make_periodic_invoices_config_from_yaml {
2185 my ($yaml_config) = @_;
2187 return if !$yaml_config;
2188 my $attr = SL::YAML::Load($yaml_config);
2189 return if 'HASH' ne ref $attr;
2190 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
2194 sub get_periodic_invoices_status {
2195 my ($self, $config) = @_;
2197 return if $self->type ne sales_order_type();
2198 return t8('not configured') if !$config;
2200 my $active = ('HASH' eq ref $config) ? $config->{active}
2201 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
2202 : die "Cannot get status of periodic invoices config";
2204 return $active ? t8('active') : t8('inactive');
2208 my ($self, $action) = @_;
2210 return '' if none { lc($action)} qw(add edit);
2213 # $::locale->text("Add Sales Order");
2214 # $::locale->text("Add Purchase Order");
2215 # $::locale->text("Add Quotation");
2216 # $::locale->text("Add Request for Quotation");
2217 # $::locale->text("Edit Sales Order");
2218 # $::locale->text("Edit Purchase Order");
2219 # $::locale->text("Edit Quotation");
2220 # $::locale->text("Edit Request for Quotation");
2222 $action = ucfirst(lc($action));
2223 return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order")
2224 : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order")
2225 : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation")
2226 : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
2230 sub get_item_cvpartnumber {
2231 my ($self, $item) = @_;
2233 return if !$self->search_cvpartnumber;
2234 return if !$self->order->customervendor;
2236 if ($self->cv eq 'vendor') {
2237 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
2238 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
2239 } elsif ($self->cv eq 'customer') {
2240 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
2241 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
2245 sub get_part_texts {
2246 my ($part_or_id, $language_or_id, %defaults) = @_;
2248 my $part = ref($part_or_id) ? $part_or_id : SL::DB::Part->load_cached($part_or_id);
2249 my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id;
2251 description => $defaults{description} // $part->description,
2252 longdescription => $defaults{longdescription} // $part->notes,
2255 return $texts unless $language_id;
2257 my $translation = SL::DB::Manager::Translation->get_first(
2259 parts_id => $part->id,
2260 language_id => $language_id,
2263 $texts->{description} = $translation->translation if $translation && $translation->translation;
2264 $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription;
2269 sub sales_order_type {
2273 sub purchase_order_type {
2277 sub sales_quotation_type {
2281 sub request_quotation_type {
2282 'request_quotation';
2286 return $_[0]->type eq sales_order_type() ? 'ordnumber'
2287 : $_[0]->type eq purchase_order_type() ? 'ordnumber'
2288 : $_[0]->type eq sales_quotation_type() ? 'quonumber'
2289 : $_[0]->type eq request_quotation_type() ? 'quonumber'
2293 sub save_and_redirect_to {
2294 my ($self, %params) = @_;
2296 my $errors = $self->save();
2298 if (scalar @{ $errors }) {
2299 $self->js->flash('error', $_) foreach @{ $errors };
2300 return $self->js->render();
2303 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
2304 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
2305 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
2306 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
2308 flash_later('info', $text);
2310 $self->redirect_to(%params, id => $self->order->id);
2314 my ($self, $addition) = @_;
2316 my $number_type = $self->order->type =~ m{order} ? 'ordnumber' : 'quonumber';
2317 my $snumbers = $number_type . '_' . $self->order->$number_type;
2319 SL::DB::History->new(
2320 trans_id => $self->order->id,
2321 employee_id => SL::DB::Manager::Employee->current->id,
2322 what_done => $self->order->type,
2323 snumbers => $snumbers,
2324 addition => $addition,
2328 sub store_doc_to_webdav_and_filemanagement {
2329 my ($self, $content, $filename, $variant) = @_;
2331 my $order = $self->order;
2334 # copy file to webdav folder
2335 if ($order->number && $::instance_conf->get_webdav_documents) {
2336 my $webdav = SL::Webdav->new(
2337 type => $order->type,
2338 number => $order->number,
2340 my $webdav_file = SL::Webdav::File->new(
2342 filename => $filename,
2345 $webdav_file->store(data => \$content);
2348 push @errors, t8('Storing the document to the WebDAV folder failed: #1', $@);
2351 if ($order->id && $::instance_conf->get_doc_storage) {
2353 SL::File->save(object_id => $order->id,
2354 object_type => $order->type,
2355 mime_type => SL::MIME->mime_type_from_ext($filename),
2356 source => 'created',
2357 file_type => 'document',
2358 file_name => $filename,
2359 file_contents => $content,
2360 print_variant => $variant);
2363 push @errors, t8('Storing the document in the storage backend failed: #1', $@);
2370 sub link_requirement_specs_linking_to_created_from_objects {
2371 my ($self, @converted_from_oe_ids) = @_;
2373 return unless @converted_from_oe_ids;
2375 my $rs_orders = SL::DB::Manager::RequirementSpecOrder->get_all(where => [ order_id => \@converted_from_oe_ids ]);
2376 foreach my $rs_order (@{ $rs_orders }) {
2377 SL::DB::RequirementSpecOrder->new(
2378 order_id => $self->order->id,
2379 requirement_spec_id => $rs_order->requirement_spec_id,
2380 version_id => $rs_order->version_id,
2385 sub set_project_in_linked_requirement_specs {
2388 my $rs_orders = SL::DB::Manager::RequirementSpecOrder->get_all(where => [ order_id => $self->order->id ]);
2389 foreach my $rs_order (@{ $rs_orders }) {
2390 next if $rs_order->requirement_spec->project_id == $self->order->globalproject_id;
2392 $rs_order->requirement_spec->update_attributes(project_id => $self->order->globalproject_id);
2404 SL::Controller::Order - controller for orders
2408 This is a new form to enter orders, completely rewritten with the use
2409 of controller and java script techniques.
2411 The aim is to provide the user a better experience and a faster workflow. Also
2412 the code should be more readable, more reliable and better to maintain.
2420 One input row, so that input happens every time at the same place.
2424 Use of pickers where possible.
2428 Possibility to enter more than one item at once.
2432 Item list in a scrollable area, so that the workflow buttons stay at
2437 Reordering item rows with drag and drop is possible. Sorting item rows is
2438 possible (by partnumber, description, qty, sellprice and discount for now).
2442 No C<update> is necessary. All entries and calculations are managed
2443 with ajax-calls and the page only reloads on C<save>.
2447 User can see changes immediately, because of the use of java script
2458 =item * C<SL/Controller/Order.pm>
2462 =item * C<template/webpages/order/form.html>
2466 =item * C<template/webpages/order/tabs/basic_data.html>
2468 Main tab for basic_data.
2470 This is the only tab here for now. "linked records" and "webdav" tabs are
2471 reused from generic code.
2475 =item * C<template/webpages/order/tabs/_business_info_row.html>
2477 For displaying information on business type
2479 =item * C<template/webpages/order/tabs/_item_input.html>
2481 The input line for items
2483 =item * C<template/webpages/order/tabs/_row.html>
2485 One row for already entered items
2487 =item * C<template/webpages/order/tabs/_tax_row.html>
2489 Displaying tax information
2491 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
2493 Dialog for selecting price and discount sources
2497 =item * C<js/kivi.Order.js>
2499 java script functions
2509 =item * price sources: little symbols showing better price / better discount
2511 =item * select units in input row?
2513 =item * check for direct delivery (workflow sales order -> purchase order)
2515 =item * access rights
2517 =item * display weights
2521 =item * optional client/user behaviour
2523 (transactions has to be set - department has to be set -
2524 force project if enabled in client config)
2528 =head1 KNOWN BUGS AND CAVEATS
2534 Customer discount is not displayed as a valid discount in price source popup
2535 (this might be a bug in price sources)
2537 (I cannot reproduce this (Bernd))
2541 No indication that <shift>-up/down expands/collapses second row.
2545 Inline creation of parts is not currently supported
2549 Table header is not sticky in the scrolling area.
2553 Sorting does not include C<position>, neither does reordering.
2555 This behavior was implemented intentionally. But we can discuss, which behavior
2556 should be implemented.
2560 =head1 To discuss / Nice to have
2566 How to expand/collapse second row. Now it can be done clicking the icon or
2571 Possibility to select PriceSources in input row?
2575 This controller uses a (changed) copy of the template for the PriceSource
2576 dialog. Maybe there could be used one code source.
2580 Rounding-differences between this controller (PriceTaxCalculator) and the old
2581 form. This is not only a problem here, but also in all parts using the PTC.
2582 There exists a ticket and a patch. This patch should be testet.
2586 An indicator, if the actual inputs are saved (like in an
2587 editor or on text processing application).
2591 A warning when leaving the page without saveing unchanged inputs.
2598 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>