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 => "salutation_general")
562 . GenericTranslations->get(
563 language_id => $::form->{language_id},
564 translation_type => "salutation_punctuation_mark") . "\n\n"
565 . GenericTranslations->get(
566 language_id => $::form->{language_id},
567 translation_type =>"preset_text_periodic_invoices_email_body"),
569 # for older configs, replace email preset text if not yet set.
570 $config->email_subject(GenericTranslations->get(
571 language_id => $::form->{language_id},
572 translation_type =>"preset_text_periodic_invoices_email_subject")
573 ) unless $config->email_subject;
575 $config->email_body(GenericTranslations->get(
576 language_id => $::form->{language_id},
577 translation_type => "salutation_general")
578 . GenericTranslations->get(
579 language_id => $::form->{language_id},
580 translation_type => "salutation_punctuation_mark") . "\n\n"
581 . GenericTranslations->get(
582 language_id => $::form->{language_id},
583 translation_type =>"preset_text_periodic_invoices_email_body")
584 ) unless $config->email_body;
586 $config->periodicity('m') if none { $_ eq $config->periodicity } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
587 $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
589 $::form->get_lists(printers => "ALL_PRINTERS",
590 charts => { key => 'ALL_CHARTS',
591 transdate => 'current_date' });
593 $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
595 if ($::form->{customer_id}) {
596 $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
597 my $customer_object = SL::DB::Manager::Customer->find_by(id => $::form->{customer_id});
598 $::form->{postal_invoice} = $customer_object->postal_invoice;
599 $::form->{email_recipient_invoice_address} = $::form->{postal_invoice} ? '' : $customer_object->invoice_mail;
600 $config->send_email(0) if $::form->{postal_invoice};
603 $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
605 popup_js_close_function => 'kivi.Order.close_periodic_invoices_config_dialog()',
606 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
611 # assign the values of the periodic invoices config dialog
612 # as yaml in the hidden tag and set the status.
613 sub action_assign_periodic_invoices_config {
616 $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
618 my $config = { active => $::form->{active} ? 1 : 0,
619 terminated => $::form->{terminated} ? 1 : 0,
620 direct_debit => $::form->{direct_debit} ? 1 : 0,
621 periodicity => (any { $_ eq $::form->{periodicity} } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES) ? $::form->{periodicity} : 'm',
622 order_value_periodicity => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
623 start_date_as_date => $::form->{start_date_as_date},
624 end_date_as_date => $::form->{end_date_as_date},
625 first_billing_date_as_date => $::form->{first_billing_date_as_date},
626 print => $::form->{print} ? 1 : 0,
627 printer_id => $::form->{print} ? $::form->{printer_id} * 1 : undef,
628 copies => $::form->{copies} * 1 ? $::form->{copies} : 1,
629 extend_automatically_by => $::form->{extend_automatically_by} * 1 || undef,
630 ar_chart_id => $::form->{ar_chart_id} * 1,
631 send_email => $::form->{send_email} ? 1 : 0,
632 email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
633 email_recipient_address => $::form->{email_recipient_address},
634 email_sender => $::form->{email_sender},
635 email_subject => $::form->{email_subject},
636 email_body => $::form->{email_body},
639 my $periodic_invoices_config = SL::YAML::Dump($config);
641 my $status = $self->get_periodic_invoices_status($config);
644 ->remove('#order_periodic_invoices_config')
645 ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
646 ->run('kivi.Order.close_periodic_invoices_config_dialog')
647 ->html('#periodic_invoices_status', $status)
648 ->flash('info', t8('The periodic invoices config has been assigned.'))
652 sub action_get_has_active_periodic_invoices {
655 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
656 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
658 my $has_active_periodic_invoices =
659 $self->type eq sales_order_type()
662 && (!$config->end_date || ($config->end_date > DateTime->today_local))
663 && $config->get_previous_billed_period_start_date;
665 $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
668 # save the order and redirect to the frontend subroutine for a new
670 sub action_save_and_delivery_order {
673 $self->save_and_redirect_to(
674 controller => 'oe.pl',
675 action => 'oe_delivery_order_from_order',
679 # save the order and redirect to the frontend subroutine for a new
681 sub action_save_and_invoice {
684 $self->save_and_redirect_to(
685 controller => 'oe.pl',
686 action => 'oe_invoice_from_order',
690 # workflow from sales order to sales quotation
691 sub action_sales_quotation {
692 $_[0]->workflow_sales_or_request_for_quotation();
695 # workflow from sales order to sales quotation
696 sub action_request_for_quotation {
697 $_[0]->workflow_sales_or_request_for_quotation();
700 # workflow from sales quotation to sales order
701 sub action_sales_order {
702 $_[0]->workflow_sales_or_purchase_order();
705 # workflow from rfq to purchase order
706 sub action_purchase_order {
707 $_[0]->workflow_sales_or_purchase_order();
710 # workflow from purchase order to ap transaction
711 sub action_save_and_ap_transaction {
714 $self->save_and_redirect_to(
715 controller => 'ap.pl',
716 action => 'add_from_purchase_order',
720 # set form elements in respect to a changed customer or vendor
722 # This action is called on an change of the customer/vendor picker.
723 sub action_customer_vendor_changed {
726 setup_order_from_cv($self->order);
729 my $cv_method = $self->cv;
731 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
732 $self->js->show('#cp_row');
734 $self->js->hide('#cp_row');
737 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
738 $self->js->show('#shipto_selection');
740 $self->js->hide('#shipto_selection');
743 if ($cv_method eq 'customer') {
744 my $show_hide = scalar @{ $self->order->customer->additional_billing_addresses } > 0 ? 'show' : 'hide';
745 $self->js->$show_hide('#billing_address_row');
748 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
751 ->replaceWith('#order_cp_id', $self->build_contact_select)
752 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
753 ->replaceWith('#shipto_inputs ', $self->build_shipto_inputs)
754 ->replaceWith('#order_billing_address_id', $self->build_billing_address_select)
755 ->replaceWith('#business_info_row', $self->build_business_info_row)
756 ->val( '#order_taxzone_id', $self->order->taxzone_id)
757 ->val( '#order_taxincluded', $self->order->taxincluded)
758 ->val( '#order_currency_id', $self->order->currency_id)
759 ->val( '#order_payment_id', $self->order->payment_id)
760 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
761 ->val( '#order_intnotes', $self->order->intnotes)
762 ->val( '#order_language_id', $self->order->$cv_method->language_id)
763 ->focus( '#order_' . $self->cv . '_id')
764 ->run('kivi.Order.update_exchangerate');
766 $self->js_redisplay_amounts_and_taxes;
767 $self->js_redisplay_cvpartnumbers;
771 # open the dialog for customer/vendor details
772 sub action_show_customer_vendor_details_dialog {
775 my $is_customer = 'customer' eq $::form->{vc};
778 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
780 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
783 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
784 $details{discount_as_percent} = $cv->discount_as_percent;
785 $details{creditlimt} = $cv->creditlimit_as_number;
786 $details{business} = $cv->business->description if $cv->business;
787 $details{language} = $cv->language_obj->description if $cv->language_obj;
788 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
789 $details{payment_terms} = $cv->payment->description if $cv->payment;
790 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
793 foreach my $entry (@{ $cv->additional_billing_addresses }) {
794 push @{ $details{ADDITIONAL_BILLING_ADDRESSES} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
797 foreach my $entry (@{ $cv->shipto }) {
798 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
800 foreach my $entry (@{ $cv->contacts }) {
801 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
804 $_[0]->render('common/show_vc_details', { layout => 0 },
805 is_customer => $is_customer,
810 # called if a unit in an existing item row is changed
811 sub action_unit_changed {
814 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
815 my $item = $self->order->items_sorted->[$idx];
817 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
818 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
823 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
824 $self->js_redisplay_line_values;
825 $self->js_redisplay_amounts_and_taxes;
829 # add an item row for a new item entered in the input row
830 sub action_add_item {
833 delete $::form->{add_item}->{create_part_type};
835 my $form_attr = $::form->{add_item};
837 return unless $form_attr->{parts_id};
839 my $item = new_item($self->order, $form_attr);
841 $self->order->add_items($item);
845 $self->get_item_cvpartnumber($item);
847 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
848 my $row_as_html = $self->p->render('order/tabs/_row',
854 if ($::form->{insert_before_item_id}) {
856 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
859 ->append('#row_table_id', $row_as_html);
862 if ( $item->part->is_assortment ) {
863 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
864 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
865 my $attr = { parts_id => $assortment_item->parts_id,
866 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
867 unit => $assortment_item->unit,
868 description => $assortment_item->part->description,
870 my $item = new_item($self->order, $attr);
872 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
873 $item->discount(1) unless $assortment_item->charge;
875 $self->order->add_items( $item );
877 $self->get_item_cvpartnumber($item);
878 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
879 my $row_as_html = $self->p->render('order/tabs/_row',
884 if ($::form->{insert_before_item_id}) {
886 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
889 ->append('#row_table_id', $row_as_html);
895 ->val('.add_item_input', '')
896 ->run('kivi.Order.init_row_handlers')
897 ->run('kivi.Order.renumber_positions')
898 ->focus('#add_item_parts_id_name');
900 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
902 $self->js_redisplay_amounts_and_taxes;
906 # add item rows for multiple items at once
907 sub action_add_multi_items {
910 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
911 return $self->js->render() unless scalar @form_attr;
914 foreach my $attr (@form_attr) {
915 my $item = new_item($self->order, $attr);
917 if ( $item->part->is_assortment ) {
918 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
919 my $attr = { parts_id => $assortment_item->parts_id,
920 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
921 unit => $assortment_item->unit,
922 description => $assortment_item->part->description,
924 my $item = new_item($self->order, $attr);
926 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
927 $item->discount(1) unless $assortment_item->charge;
932 $self->order->add_items(@items);
936 foreach my $item (@items) {
937 $self->get_item_cvpartnumber($item);
938 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
939 my $row_as_html = $self->p->render('order/tabs/_row',
945 if ($::form->{insert_before_item_id}) {
947 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
950 ->append('#row_table_id', $row_as_html);
955 ->run('kivi.Part.close_picker_dialogs')
956 ->run('kivi.Order.init_row_handlers')
957 ->run('kivi.Order.renumber_positions')
958 ->focus('#add_item_parts_id_name');
960 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
962 $self->js_redisplay_amounts_and_taxes;
966 # recalculate all linetotals, amounts and taxes and redisplay them
967 sub action_recalc_amounts_and_taxes {
972 $self->js_redisplay_line_values;
973 $self->js_redisplay_amounts_and_taxes;
977 sub action_update_exchangerate {
981 is_standard => $self->order->currency_id == $::instance_conf->get_currency_id,
982 currency_name => $self->order->currency->name,
983 exchangerate => $self->order->daily_exchangerate_as_null_number,
986 $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
989 # redisplay item rows if they are sorted by an attribute
990 sub action_reorder_items {
994 partnumber => sub { $_[0]->part->partnumber },
995 description => sub { $_[0]->description },
996 qty => sub { $_[0]->qty },
997 sellprice => sub { $_[0]->sellprice },
998 discount => sub { $_[0]->discount },
999 cvpartnumber => sub { $_[0]->{cvpartnumber} },
1002 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1004 my $method = $sort_keys{$::form->{order_by}};
1005 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
1006 if ($::form->{sort_dir}) {
1007 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
1008 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
1010 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
1013 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
1014 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
1016 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
1020 ->run('kivi.Order.redisplay_items', \@to_sort)
1024 # show the popup to choose a price/discount source
1025 sub action_price_popup {
1028 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
1029 my $item = $self->order->items_sorted->[$idx];
1031 $self->render_price_dialog($item);
1034 # save the order in a session variable and redirect to the part controller
1035 sub action_create_part {
1038 my $previousform = $::auth->save_form_in_session(non_scalars => 1);
1040 my $callback = $self->url_for(
1041 action => 'return_from_create_part',
1042 type => $self->type, # type is needed for check_auth on return
1043 previousform => $previousform,
1046 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.'));
1048 my @redirect_params = (
1049 controller => 'Part',
1051 part_type => $::form->{add_item}->{create_part_type},
1052 callback => $callback,
1056 $self->redirect_to(@redirect_params);
1059 sub action_return_from_create_part {
1062 $self->{created_part} = SL::DB::Part->new(id => delete $::form->{new_parts_id})->load if $::form->{new_parts_id};
1064 $::auth->restore_form_from_session(delete $::form->{previousform});
1066 # set item ids to new fake id, to identify them as new items
1067 foreach my $item (@{$self->order->items_sorted}) {
1068 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1072 $self->get_unalterable_data();
1073 $self->pre_render();
1075 # trigger rendering values for second row/longdescription as hidden,
1076 # because they are loaded only on demand. So we need to keep the values
1078 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1079 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
1083 title => $self->get_title_for('edit'),
1084 %{$self->{template_args}}
1089 # load the second row for one or more items
1091 # This action gets the html code for all items second rows by rendering a template for
1092 # the second row and sets the html code via client js.
1093 sub action_load_second_rows {
1096 $self->recalc() if $self->order->is_sales; # for margin calculation
1098 foreach my $item_id (@{ $::form->{item_ids} }) {
1099 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1100 my $item = $self->order->items_sorted->[$idx];
1102 $self->js_load_second_row($item, $item_id, 0);
1105 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
1107 $self->js->render();
1110 # update description, notes and sellprice from master data
1111 sub action_update_row_from_master_data {
1114 foreach my $item_id (@{ $::form->{item_ids} }) {
1115 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1116 my $item = $self->order->items_sorted->[$idx];
1117 my $texts = get_part_texts($item->part, $self->order->language_id);
1119 $item->description($texts->{description});
1120 $item->longdescription($texts->{longdescription});
1122 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1125 if ($item->part->is_assortment) {
1126 # add assortment items with price 0, as the components carry the price
1127 $price_src = $price_source->price_from_source("");
1128 $price_src->price(0);
1130 $price_src = $price_source->best_price
1131 ? $price_source->best_price
1132 : $price_source->price_from_source("");
1133 $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate;
1134 $price_src->price(0) if !$price_source->best_price;
1138 $item->sellprice($price_src->price);
1139 $item->active_price_source($price_src);
1142 ->run('kivi.Order.update_sellprice', $item_id, $item->sellprice_as_number)
1143 ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
1144 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
1145 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
1147 if ($self->search_cvpartnumber) {
1148 $self->get_item_cvpartnumber($item);
1149 $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
1154 $self->js_redisplay_line_values;
1155 $self->js_redisplay_amounts_and_taxes;
1157 $self->js->render();
1160 sub js_load_second_row {
1161 my ($self, $item, $item_id, $do_parse) = @_;
1164 # Parse values from form (they are formated while rendering (template)).
1165 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1166 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1167 foreach my $var (@{ $item->cvars_by_config }) {
1168 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1170 $item->parse_custom_variable_values;
1173 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1176 ->html('#second_row_' . $item_id, $row_as_html)
1177 ->data('#second_row_' . $item_id, 'loaded', 1);
1180 sub js_redisplay_line_values {
1183 my $is_sales = $self->order->is_sales;
1185 # sales orders with margins
1190 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1191 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1192 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1193 ]} @{ $self->order->items_sorted };
1197 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1198 ]} @{ $self->order->items_sorted };
1202 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1205 sub js_redisplay_amounts_and_taxes {
1208 if (scalar @{ $self->{taxes} }) {
1209 $self->js->show('#taxincluded_row_id');
1211 $self->js->hide('#taxincluded_row_id');
1214 if ($self->order->taxincluded) {
1215 $self->js->hide('#subtotal_row_id');
1217 $self->js->show('#subtotal_row_id');
1220 if ($self->order->is_sales) {
1221 my $is_neg = $self->order->marge_total < 0;
1223 ->html('#marge_total_id', $::form->format_amount(\%::myconfig, $self->order->marge_total, 2))
1224 ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
1225 ->action_if( $is_neg, 'addClass', '#marge_total_id', 'plus0')
1226 ->action_if( $is_neg, 'addClass', '#marge_percent_id', 'plus0')
1227 ->action_if( $is_neg, 'addClass', '#marge_percent_sign_id', 'plus0')
1228 ->action_if(!$is_neg, 'removeClass', '#marge_total_id', 'plus0')
1229 ->action_if(!$is_neg, 'removeClass', '#marge_percent_id', 'plus0')
1230 ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
1234 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
1235 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
1236 ->remove('.tax_row')
1237 ->insertBefore($self->build_tax_rows, '#amount_row_id');
1240 sub js_redisplay_cvpartnumbers {
1243 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1245 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1248 ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
1251 sub js_reset_order_and_item_ids_after_save {
1255 ->val('#id', $self->order->id)
1256 ->val('#converted_from_oe_id', '')
1257 ->val('#order_' . $self->nr_key(), $self->order->number);
1260 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1261 next if !$self->order->items_sorted->[$idx]->id;
1262 next if $form_item_id !~ m{^new};
1264 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1265 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1266 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1270 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1277 sub init_valid_types {
1278 [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
1284 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1285 die "Not a valid type for order";
1288 $self->type($::form->{type});
1294 my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer'
1295 : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
1296 : die "Not a valid type for order";
1301 sub init_search_cvpartnumber {
1304 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1305 my $search_cvpartnumber;
1306 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1307 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1309 return $search_cvpartnumber;
1312 sub init_show_update_button {
1315 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1326 sub init_all_price_factors {
1327 SL::DB::Manager::PriceFactor->get_all;
1330 sub init_part_picker_classification_ids {
1332 my $attribute = 'used_for_' . ($self->type =~ m{sales} ? 'sale' : 'purchase');
1334 return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => [ $attribute => 1 ]) } ];
1340 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
1342 my $right = $right_for->{ $self->type };
1343 $right ||= 'DOES_NOT_EXIST';
1345 $::auth->assert($right);
1348 # build the selection box for contacts
1350 # Needed, if customer/vendor changed.
1351 sub build_contact_select {
1354 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1355 value_key => 'cp_id',
1356 title_key => 'full_name_dep',
1357 default => $self->order->cp_id,
1359 style => 'width: 300px',
1363 # build the selection box for the additional billing address
1365 # Needed, if customer/vendor changed.
1366 sub build_billing_address_select {
1369 return '' if $self->cv ne 'customer';
1371 select_tag('order.billing_address_id',
1372 [ {displayable_id => '', id => ''}, $self->order->{$self->cv}->additional_billing_addresses ],
1374 title_key => 'displayable_id',
1375 default => $self->order->billing_address_id,
1377 style => 'width: 300px',
1381 # build the selection box for shiptos
1383 # Needed, if customer/vendor changed.
1384 sub build_shipto_select {
1387 select_tag('order.shipto_id',
1388 [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
1389 value_key => 'shipto_id',
1390 title_key => 'displayable_id',
1391 default => $self->order->shipto_id,
1393 style => 'width: 300px',
1397 # build the inputs for the cusom shipto dialog
1399 # Needed, if customer/vendor changed.
1400 sub build_shipto_inputs {
1403 my $content = $self->p->render('common/_ship_to_dialog',
1404 vc_obj => $self->order->customervendor,
1405 cs_obj => $self->order->custom_shipto,
1406 cvars => $self->order->custom_shipto->cvars_by_config,
1407 id_selector => '#order_shipto_id');
1409 div_tag($content, id => 'shipto_inputs');
1412 # render the info line for business
1414 # Needed, if customer/vendor changed.
1415 sub build_business_info_row
1417 $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
1420 # build the rows for displaying taxes
1422 # Called if amounts where recalculated and redisplayed.
1423 sub build_tax_rows {
1427 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
1428 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
1430 return $rows_as_html;
1434 sub render_price_dialog {
1435 my ($self, $record_item) = @_;
1437 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1441 'kivi.io.price_chooser_dialog',
1442 t8('Available Prices'),
1443 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1448 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1449 # $self->js->show('#dialog_flash_error');
1458 return if !$::form->{id};
1460 $self->order(SL::DB::Order->new(id => $::form->{id})->load);
1462 # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
1463 # You need a custom shipto object to call cvars_by_config to get the cvars.
1464 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
1466 return $self->order;
1469 # load or create a new order object
1471 # And assign changes from the form to this object.
1472 # If the order is loaded from db, check if items are deleted in the form,
1473 # remove them form the object and collect them for removing from db on saving.
1474 # Then create/update items from form (via make_item) and add them.
1478 # add_items adds items to an order with no items for saving, but they cannot
1479 # be retrieved via items until the order is saved. Adding empty items to new
1480 # order here solves this problem.
1482 $order = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1483 $order ||= SL::DB::Order->new(orderitems => [],
1484 quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())),
1485 currency_id => $::instance_conf->get_currency_id(),);
1487 my $cv_id_method = $self->cv . '_id';
1488 if (!$::form->{id} && $::form->{$cv_id_method}) {
1489 $order->$cv_id_method($::form->{$cv_id_method});
1490 setup_order_from_cv($order);
1493 my $form_orderitems = delete $::form->{order}->{orderitems};
1494 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1496 $order->assign_attributes(%{$::form->{order}});
1498 $self->setup_custom_shipto_from_form($order, $::form);
1500 if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) {
1501 my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
1502 $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
1505 # remove deleted items
1506 $self->item_ids_to_delete([]);
1507 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1508 my $item = $order->orderitems->[$idx];
1509 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1510 splice @{$order->orderitems}, $idx, 1;
1511 push @{$self->item_ids_to_delete}, $item->id;
1517 foreach my $form_attr (@{$form_orderitems}) {
1518 my $item = make_item($order, $form_attr);
1519 $item->position($pos);
1523 $order->add_items(grep {!$_->id} @items);
1528 # create or update items from form
1530 # Make item objects from form values. For items already existing read from db.
1531 # Create a new item else. And assign attributes.
1533 my ($record, $attr) = @_;
1536 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1538 my $is_new = !$item;
1540 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1541 # they cannot be retrieved via custom_variables until the order/orderitem is
1542 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1543 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1545 $item->assign_attributes(%$attr);
1548 my $texts = get_part_texts($item->part, $record->language_id);
1549 $item->longdescription($texts->{longdescription}) if !defined $attr->{longdescription};
1550 $item->project_id($record->globalproject_id) if !defined $attr->{project_id};
1551 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number};
1559 # This is used to add one item
1561 my ($record, $attr) = @_;
1563 my $item = SL::DB::OrderItem->new;
1565 # Remove attributes where the user left or set the inputs empty.
1566 # So these attributes will be undefined and we can distinguish them
1567 # from zero later on.
1568 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1569 delete $attr->{$_} if $attr->{$_} eq '';
1572 $item->assign_attributes(%$attr);
1574 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1575 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1577 $item->unit($part->unit) if !$item->unit;
1580 if ( $part->is_assortment ) {
1581 # add assortment items with price 0, as the components carry the price
1582 $price_src = $price_source->price_from_source("");
1583 $price_src->price(0);
1584 } elsif (defined $item->sellprice) {
1585 $price_src = $price_source->price_from_source("");
1586 $price_src->price($item->sellprice);
1588 $price_src = $price_source->best_price
1589 ? $price_source->best_price
1590 : $price_source->price_from_source("");
1591 $price_src->price($::form->round_amount($price_src->price / $record->exchangerate, 5)) if $record->exchangerate;
1592 $price_src->price(0) if !$price_source->best_price;
1596 if (defined $item->discount) {
1597 $discount_src = $price_source->discount_from_source("");
1598 $discount_src->discount($item->discount);
1600 $discount_src = $price_source->best_discount
1601 ? $price_source->best_discount
1602 : $price_source->discount_from_source("");
1603 $discount_src->discount(0) if !$price_source->best_discount;
1607 $new_attr{part} = $part;
1608 $new_attr{description} = $part->description if ! $item->description;
1609 $new_attr{qty} = 1.0 if ! $item->qty;
1610 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1611 $new_attr{sellprice} = $price_src->price;
1612 $new_attr{discount} = $discount_src->discount;
1613 $new_attr{active_price_source} = $price_src;
1614 $new_attr{active_discount_source} = $discount_src;
1615 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1616 $new_attr{project_id} = $record->globalproject_id;
1617 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1619 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1620 # they cannot be retrieved via custom_variables until the order/orderitem is
1621 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1622 $new_attr{custom_variables} = [];
1624 my $texts = get_part_texts($part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription});
1626 $item->assign_attributes(%new_attr, %{ $texts });
1631 sub setup_order_from_cv {
1634 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id));
1636 $order->intnotes($order->customervendor->notes);
1638 return if !$order->is_sales;
1640 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1641 $order->taxincluded(defined($order->customer->taxincluded_checked)
1642 ? $order->customer->taxincluded_checked
1643 : $::myconfig{taxincluded_checked});
1645 my $address = $order->customer->default_billing_address;;
1646 $order->billing_address_id($address ? $address->id : undef);
1649 # setup custom shipto from form
1651 # The dialog returns form variables starting with 'shipto' and cvars starting
1652 # with 'shiptocvar_'.
1653 # Mark it to be deleted if a shipto from master data is selected
1654 # (i.e. order has a shipto).
1655 # Else, update or create a new custom shipto. If the fields are empty, it
1656 # will not be saved on save.
1657 sub setup_custom_shipto_from_form {
1658 my ($self, $order, $form) = @_;
1660 if ($order->shipto) {
1661 $self->is_custom_shipto_to_delete(1);
1663 my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1665 my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
1666 my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
1668 $custom_shipto->assign_attributes(%$shipto_attrs);
1669 $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
1673 # recalculate prices and taxes
1675 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1679 my %pat = $self->order->calculate_prices_and_taxes();
1681 $self->{taxes} = [];
1682 foreach my $tax_id (keys %{ $pat{taxes_by_tax_id} }) {
1683 my $netamount = sum0 map { $pat{amounts}->{$_}->{amount} } grep { $pat{amounts}->{$_}->{tax_id} == $tax_id } keys %{ $pat{amounts} };
1685 push(@{ $self->{taxes} }, { amount => $pat{taxes_by_tax_id}->{$tax_id},
1686 netamount => $netamount,
1687 tax => SL::DB::Tax->new(id => $tax_id)->load });
1689 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items_sorted}, @{$pat{items}};
1692 # get data for saving, printing, ..., that is not changed in the form
1694 # Only cvars for now.
1695 sub get_unalterable_data {
1698 foreach my $item (@{ $self->order->items }) {
1699 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1700 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1701 foreach my $var (@{ $item->cvars_by_config }) {
1702 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1704 $item->parse_custom_variable_values;
1710 # And remove related files in the spool directory
1715 my $db = $self->order->db;
1717 $db->with_transaction(
1719 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1720 $self->order->delete;
1721 my $spool = $::lx_office_conf{paths}->{spool};
1722 unlink map { "$spool/$_" } @spoolfiles if $spool;
1724 $self->save_history('DELETED');
1727 }) || push(@{$errors}, $db->error);
1734 # And delete items that are deleted in the form.
1739 my $db = $self->order->db;
1741 $db->with_transaction(sub {
1742 # delete custom shipto if it is to be deleted or if it is empty
1743 if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
1744 $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
1745 $self->order->custom_shipto(undef);
1748 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1749 $self->order->save(cascade => 1);
1752 if ($::form->{converted_from_oe_id}) {
1753 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1755 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1756 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1757 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
1758 $src->link_to_record($self->order);
1760 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1762 foreach (@{ $self->order->items_sorted }) {
1763 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1765 SL::DB::RecordLink->new(from_table => 'orderitems',
1766 from_id => $from_id,
1767 to_table => 'orderitems',
1774 $self->link_requirement_specs_linking_to_created_from_objects(@converted_from_oe_ids);
1777 $self->set_project_in_linked_requirement_specs if $self->order->globalproject_id;
1779 $self->save_history('SAVED');
1782 }) || push(@{$errors}, $db->error);
1787 sub workflow_sales_or_request_for_quotation {
1791 my $errors = $self->save();
1793 if (scalar @{ $errors }) {
1794 $self->js->flash('error', $_) for @{ $errors };
1795 return $self->js->render();
1798 my $destination_type = $::form->{type} eq sales_order_type() ? sales_quotation_type() : request_quotation_type();
1800 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1801 $self->{converted_from_oe_id} = delete $::form->{id};
1803 # set item ids to new fake id, to identify them as new items
1804 foreach my $item (@{$self->order->items_sorted}) {
1805 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1809 $::form->{type} = $destination_type;
1810 $self->type($self->init_type);
1811 $self->cv ($self->init_cv);
1815 $self->get_unalterable_data();
1816 $self->pre_render();
1818 # trigger rendering values for second row as hidden, because they
1819 # are loaded only on demand. So we need to keep the values from the
1821 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1825 title => $self->get_title_for('edit'),
1826 %{$self->{template_args}}
1830 sub workflow_sales_or_purchase_order {
1834 my $errors = $self->save();
1836 if (scalar @{ $errors }) {
1837 $self->js->flash('error', $_) foreach @{ $errors };
1838 return $self->js->render();
1841 my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type()
1842 : $::form->{type} eq request_quotation_type() ? purchase_order_type()
1843 : $::form->{type} eq purchase_order_type() ? sales_order_type()
1844 : $::form->{type} eq sales_order_type() ? purchase_order_type()
1847 # check for direct delivery
1848 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
1850 if ( $::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()
1851 && $::form->{use_shipto} && $self->order->shipto) {
1852 $custom_shipto = $self->order->shipto->clone('SL::DB::Order');
1855 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1856 $self->{converted_from_oe_id} = delete $::form->{id};
1858 # set item ids to new fake id, to identify them as new items
1859 foreach my $item (@{$self->order->items_sorted}) {
1860 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1863 if ($::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()) {
1864 if ($::form->{use_shipto}) {
1865 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
1867 # remove any custom shipto if not wanted
1868 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1873 $::form->{type} = $destination_type;
1874 $self->type($self->init_type);
1875 $self->cv ($self->init_cv);
1879 $self->get_unalterable_data();
1880 $self->pre_render();
1882 # trigger rendering values for second row as hidden, because they
1883 # are loaded only on demand. So we need to keep the values from the
1885 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1889 title => $self->get_title_for('edit'),
1890 %{$self->{template_args}}
1898 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1899 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
1900 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1901 $self->{all_languages} = SL::DB::Manager::Language->get_all_sorted();
1902 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1905 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1908 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1910 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1911 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1912 $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
1913 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1914 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1916 my $print_form = Form->new('');
1917 $print_form->{type} = $self->type;
1918 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1919 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1920 form => $print_form,
1921 options => {dialog_name_prefix => 'print_options.',
1925 no_opendocument => 0,
1929 foreach my $item (@{$self->order->orderitems}) {
1930 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1931 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1932 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1935 if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
1936 # Calculate shipped qtys here to prevent calling calculate for every item via the items method.
1937 # Do not use write_to_objects to prevent order->delivered to be set, because this should be
1938 # the value from db, which can be set manually or is set when linked delivery orders are saved.
1939 SL::Helper::ShippedQty->new->calculate($self->order)->write_to(\@{$self->order->items});
1942 if ($self->order->number && $::instance_conf->get_webdav) {
1943 my $webdav = SL::Webdav->new(
1944 type => $self->type,
1945 number => $self->order->number,
1947 my @all_objects = $webdav->get_all_objects;
1948 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1950 link => File::Spec->catfile($_->full_filedescriptor),
1954 if ( (any { $self->type eq $_ } (sales_quotation_type(), sales_order_type()))
1955 && $::instance_conf->get_transport_cost_reminder_article_number_id ) {
1956 $self->{template_args}->{transport_cost_reminder_article} = SL::DB::Part->new(id => $::instance_conf->get_transport_cost_reminder_article_number_id)->load;
1959 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1961 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.Validator kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
1962 edit_periodic_invoices_config calculate_qty follow_up show_history);
1963 $self->setup_edit_action_bar;
1966 sub setup_edit_action_bar {
1967 my ($self, %params) = @_;
1969 my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
1970 || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
1971 || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
1973 my @req_trans_cost_art = qw(kivi.Order.check_transport_cost_article_presence) x!!$::instance_conf->get_transport_cost_reminder_article_number_id;
1974 my @req_cusordnumber = qw(kivi.Order.check_cusordnumber_presence) x($self->type eq sales_order_type() && $::instance_conf->get_order_warn_no_cusordnumber);
1976 for my $bar ($::request->layout->get('actionbar')) {
1981 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1982 $::instance_conf->get_order_warn_no_deliverydate,
1984 checks => [ 'kivi.Order.check_save_active_periodic_invoices', ['kivi.validate_form','#order_form'],
1985 @req_trans_cost_art, @req_cusordnumber,
1990 call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1991 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
1992 @req_trans_cost_art, @req_cusordnumber,
1994 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1996 ], # end of combobox "Save"
2003 t8('Save and Quotation'),
2004 submit => [ '#order_form', { action => "Order/sales_quotation" } ],
2005 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2006 only_if => (any { $self->type eq $_ } (sales_order_type())),
2010 submit => [ '#order_form', { action => "Order/request_for_quotation" } ],
2011 only_if => (any { $self->type eq $_ } (purchase_order_type())),
2014 t8('Save and Sales Order'),
2015 submit => [ '#order_form', { action => "Order/sales_order" } ],
2016 checks => [ @req_trans_cost_art ],
2017 only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
2020 t8('Save and Purchase Order'),
2021 call => [ 'kivi.Order.purchase_order_check_for_direct_delivery' ],
2022 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2023 only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
2026 t8('Save and Delivery Order'),
2027 call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
2028 $::instance_conf->get_order_warn_no_deliverydate,
2030 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2031 @req_trans_cost_art, @req_cusordnumber,
2033 only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type()))
2036 t8('Save and Invoice'),
2037 call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
2038 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2039 @req_trans_cost_art, @req_cusordnumber,
2043 t8('Save and AP Transaction'),
2044 call => [ 'kivi.Order.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
2045 only_if => (any { $self->type eq $_ } (purchase_order_type()))
2048 ], # end of combobox "Workflow"
2055 t8('Save and preview PDF'),
2056 call => [ 'kivi.Order.save', 'preview_pdf', $::instance_conf->get_order_warn_duplicate_parts,
2057 $::instance_conf->get_order_warn_no_deliverydate,
2059 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2062 t8('Save and print'),
2063 call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts,
2064 $::instance_conf->get_order_warn_no_deliverydate,
2066 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2069 t8('Save and E-mail'),
2070 id => 'save_and_email_action',
2071 call => [ 'kivi.Order.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts,
2072 $::instance_conf->get_order_warn_no_deliverydate,
2074 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
2077 t8('Download attachments of all parts'),
2078 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
2079 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
2080 only_if => $::instance_conf->get_doc_storage,
2082 ], # end of combobox "Export"
2086 call => [ 'kivi.Order.delete_order' ],
2087 confirm => $::locale->text('Do you really want to delete this object?'),
2088 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
2089 only_if => $deletion_allowed,
2098 call => [ 'set_history_window', $self->order->id, 'id' ],
2099 disabled => !$self->order->id ? t8('This record has not been saved yet.') : undef,
2103 call => [ 'kivi.Order.follow_up_window' ],
2104 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
2105 only_if => $::auth->assert('productivity', 1),
2107 ], # end of combobox "more"
2113 my ($self, $doc_ref, $params) = @_;
2115 my $order = $self->order;
2118 my $print_form = Form->new('');
2119 $print_form->{type} = $order->type;
2120 $print_form->{formname} = $params->{formname} || $order->type;
2121 $print_form->{format} = $params->{format} || 'pdf';
2122 $print_form->{media} = $params->{media} || 'file';
2123 $print_form->{groupitems} = $params->{groupitems};
2124 $print_form->{printer_id} = $params->{printer_id};
2125 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
2127 $order->language($params->{language});
2128 $order->flatten_to_form($print_form, format_amounts => 1);
2132 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
2133 $template_ext = 'odt';
2134 $template_type = 'OpenDocument';
2135 } elsif ($print_form->{format} =~ m{html}i) {
2136 $template_ext = 'html';
2137 $template_type = 'HTML';
2140 # search for the template
2141 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
2142 name => $print_form->{formname},
2143 extension => $template_ext,
2144 email => $print_form->{media} eq 'email',
2145 language => $params->{language},
2146 printer_id => $print_form->{printer_id},
2149 if (!defined $template_file) {
2150 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);
2153 return @errors if scalar @errors;
2155 $print_form->throw_on_error(sub {
2157 $print_form->prepare_for_printing;
2159 $$doc_ref = SL::Helper::CreatePDF->create_pdf(
2160 format => $print_form->{format},
2161 template_type => $template_type,
2162 template => $template_file,
2163 variables => $print_form,
2164 variable_content_types => {
2165 longdescription => 'html',
2166 partnotes => 'html',
2168 $::form->get_variable_content_types_for_cvars,
2172 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
2178 sub get_files_for_email_dialog {
2181 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
2183 return %files if !$::instance_conf->get_doc_storage;
2185 if ($self->order->id) {
2186 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
2187 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
2188 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
2189 $files{project_files} = [ SL::File->get_all( object_id => $self->order->globalproject_id, object_type => 'project', file_type => 'attachment') ];
2193 uniq_by { $_->{id} }
2195 +{ id => $_->part->id,
2196 partnumber => $_->part->partnumber }
2197 } @{$self->order->items_sorted};
2199 foreach my $part (@parts) {
2200 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
2201 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
2204 foreach my $key (keys %files) {
2205 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
2211 sub make_periodic_invoices_config_from_yaml {
2212 my ($yaml_config) = @_;
2214 return if !$yaml_config;
2215 my $attr = SL::YAML::Load($yaml_config);
2216 return if 'HASH' ne ref $attr;
2217 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
2221 sub get_periodic_invoices_status {
2222 my ($self, $config) = @_;
2224 return if $self->type ne sales_order_type();
2225 return t8('not configured') if !$config;
2227 my $active = ('HASH' eq ref $config) ? $config->{active}
2228 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
2229 : die "Cannot get status of periodic invoices config";
2231 return $active ? t8('active') : t8('inactive');
2235 my ($self, $action) = @_;
2237 return '' if none { lc($action)} qw(add edit);
2240 # $::locale->text("Add Sales Order");
2241 # $::locale->text("Add Purchase Order");
2242 # $::locale->text("Add Quotation");
2243 # $::locale->text("Add Request for Quotation");
2244 # $::locale->text("Edit Sales Order");
2245 # $::locale->text("Edit Purchase Order");
2246 # $::locale->text("Edit Quotation");
2247 # $::locale->text("Edit Request for Quotation");
2249 $action = ucfirst(lc($action));
2250 return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order")
2251 : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order")
2252 : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation")
2253 : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
2257 sub get_item_cvpartnumber {
2258 my ($self, $item) = @_;
2260 return if !$self->search_cvpartnumber;
2261 return if !$self->order->customervendor;
2263 if ($self->cv eq 'vendor') {
2264 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
2265 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
2266 } elsif ($self->cv eq 'customer') {
2267 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
2268 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
2272 sub get_part_texts {
2273 my ($part_or_id, $language_or_id, %defaults) = @_;
2275 my $part = ref($part_or_id) ? $part_or_id : SL::DB::Part->load_cached($part_or_id);
2276 my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id;
2278 description => $defaults{description} // $part->description,
2279 longdescription => $defaults{longdescription} // $part->notes,
2282 return $texts unless $language_id;
2284 my $translation = SL::DB::Manager::Translation->get_first(
2286 parts_id => $part->id,
2287 language_id => $language_id,
2290 $texts->{description} = $translation->translation if $translation && $translation->translation;
2291 $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription;
2296 sub sales_order_type {
2300 sub purchase_order_type {
2304 sub sales_quotation_type {
2308 sub request_quotation_type {
2309 'request_quotation';
2313 return $_[0]->type eq sales_order_type() ? 'ordnumber'
2314 : $_[0]->type eq purchase_order_type() ? 'ordnumber'
2315 : $_[0]->type eq sales_quotation_type() ? 'quonumber'
2316 : $_[0]->type eq request_quotation_type() ? 'quonumber'
2320 sub save_and_redirect_to {
2321 my ($self, %params) = @_;
2323 my $errors = $self->save();
2325 if (scalar @{ $errors }) {
2326 $self->js->flash('error', $_) foreach @{ $errors };
2327 return $self->js->render();
2330 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
2331 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
2332 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
2333 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
2335 flash_later('info', $text);
2337 $self->redirect_to(%params, id => $self->order->id);
2341 my ($self, $addition) = @_;
2343 my $number_type = $self->order->type =~ m{order} ? 'ordnumber' : 'quonumber';
2344 my $snumbers = $number_type . '_' . $self->order->$number_type;
2346 SL::DB::History->new(
2347 trans_id => $self->order->id,
2348 employee_id => SL::DB::Manager::Employee->current->id,
2349 what_done => $self->order->type,
2350 snumbers => $snumbers,
2351 addition => $addition,
2355 sub store_doc_to_webdav_and_filemanagement {
2356 my ($self, $content, $filename, $variant) = @_;
2358 my $order = $self->order;
2361 # copy file to webdav folder
2362 if ($order->number && $::instance_conf->get_webdav_documents) {
2363 my $webdav = SL::Webdav->new(
2364 type => $order->type,
2365 number => $order->number,
2367 my $webdav_file = SL::Webdav::File->new(
2369 filename => $filename,
2372 $webdav_file->store(data => \$content);
2375 push @errors, t8('Storing the document to the WebDAV folder failed: #1', $@);
2378 if ($order->id && $::instance_conf->get_doc_storage) {
2380 SL::File->save(object_id => $order->id,
2381 object_type => $order->type,
2382 mime_type => SL::MIME->mime_type_from_ext($filename),
2383 source => 'created',
2384 file_type => 'document',
2385 file_name => $filename,
2386 file_contents => $content,
2387 print_variant => $variant);
2390 push @errors, t8('Storing the document in the storage backend failed: #1', $@);
2397 sub link_requirement_specs_linking_to_created_from_objects {
2398 my ($self, @converted_from_oe_ids) = @_;
2400 return unless @converted_from_oe_ids;
2402 my $rs_orders = SL::DB::Manager::RequirementSpecOrder->get_all(where => [ order_id => \@converted_from_oe_ids ]);
2403 foreach my $rs_order (@{ $rs_orders }) {
2404 SL::DB::RequirementSpecOrder->new(
2405 order_id => $self->order->id,
2406 requirement_spec_id => $rs_order->requirement_spec_id,
2407 version_id => $rs_order->version_id,
2412 sub set_project_in_linked_requirement_specs {
2415 my $rs_orders = SL::DB::Manager::RequirementSpecOrder->get_all(where => [ order_id => $self->order->id ]);
2416 foreach my $rs_order (@{ $rs_orders }) {
2417 next if $rs_order->requirement_spec->project_id == $self->order->globalproject_id;
2419 $rs_order->requirement_spec->update_attributes(project_id => $self->order->globalproject_id);
2431 SL::Controller::Order - controller for orders
2435 This is a new form to enter orders, completely rewritten with the use
2436 of controller and java script techniques.
2438 The aim is to provide the user a better experience and a faster workflow. Also
2439 the code should be more readable, more reliable and better to maintain.
2447 One input row, so that input happens every time at the same place.
2451 Use of pickers where possible.
2455 Possibility to enter more than one item at once.
2459 Item list in a scrollable area, so that the workflow buttons stay at
2464 Reordering item rows with drag and drop is possible. Sorting item rows is
2465 possible (by partnumber, description, qty, sellprice and discount for now).
2469 No C<update> is necessary. All entries and calculations are managed
2470 with ajax-calls and the page only reloads on C<save>.
2474 User can see changes immediately, because of the use of java script
2485 =item * C<SL/Controller/Order.pm>
2489 =item * C<template/webpages/order/form.html>
2493 =item * C<template/webpages/order/tabs/basic_data.html>
2495 Main tab for basic_data.
2497 This is the only tab here for now. "linked records" and "webdav" tabs are
2498 reused from generic code.
2502 =item * C<template/webpages/order/tabs/_business_info_row.html>
2504 For displaying information on business type
2506 =item * C<template/webpages/order/tabs/_item_input.html>
2508 The input line for items
2510 =item * C<template/webpages/order/tabs/_row.html>
2512 One row for already entered items
2514 =item * C<template/webpages/order/tabs/_tax_row.html>
2516 Displaying tax information
2518 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
2520 Dialog for selecting price and discount sources
2524 =item * C<js/kivi.Order.js>
2526 java script functions
2536 =item * price sources: little symbols showing better price / better discount
2538 =item * select units in input row?
2540 =item * check for direct delivery (workflow sales order -> purchase order)
2542 =item * access rights
2544 =item * display weights
2548 =item * optional client/user behaviour
2550 (transactions has to be set - department has to be set -
2551 force project if enabled in client config)
2555 =head1 KNOWN BUGS AND CAVEATS
2561 Customer discount is not displayed as a valid discount in price source popup
2562 (this might be a bug in price sources)
2564 (I cannot reproduce this (Bernd))
2568 No indication that <shift>-up/down expands/collapses second row.
2572 Inline creation of parts is not currently supported
2576 Table header is not sticky in the scrolling area.
2580 Sorting does not include C<position>, neither does reordering.
2582 This behavior was implemented intentionally. But we can discuss, which behavior
2583 should be implemented.
2587 =head1 To discuss / Nice to have
2593 How to expand/collapse second row. Now it can be done clicking the icon or
2598 Possibility to select PriceSources in input row?
2602 This controller uses a (changed) copy of the template for the PriceSource
2603 dialog. Maybe there could be used one code source.
2607 Rounding-differences between this controller (PriceTaxCalculator) and the old
2608 form. This is not only a problem here, but also in all parts using the PTC.
2609 There exists a ticket and a patch. This patch should be testet.
2613 An indicator, if the actual inputs are saved (like in an
2614 editor or on text processing application).
2618 A warning when leaving the page without saveing unchanged inputs.
2625 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>