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 and opendocument by now
284 if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf)) {
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 $pdf_filename = $form->generate_attachment_filename();
303 my @errors = $self->generate_pdf(\$pdf, { 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('Conversion to PDF failed: #1', $errors[0]))->render;
312 if ($media eq 'screen') {
314 $self->js->flash('info', t8('The PDF has been created'));
317 type => SL::MIME->mime_type_from_ext($pdf_filename),
318 name => $pdf_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 PDF has been printed'));
333 my @warnings = $self->store_pdf_to_webdav_and_filemanagement($pdf, $pdf_filename);
334 if (scalar @warnings) {
335 $self->js->flash('warning', $_) for @warnings;
338 $self->save_history('PRINTED');
341 ->run('kivi.ActionBar.setEnabled', '#save_and_email_action')
344 sub action_preview_pdf {
347 my $errors = $self->save();
348 if (scalar @{ $errors }) {
349 $self->js->flash('error', $_) foreach @{ $errors };
350 return $self->js->render();
353 $self->js_reset_order_and_item_ids_after_save;
356 my $media = 'screen';
357 my $formname = $self->type;
360 # create a form for generate_attachment_filename
361 my $form = Form->new;
362 $form->{$self->nr_key()} = $self->order->number;
363 $form->{type} = $self->type;
364 $form->{format} = $format;
365 $form->{formname} = $formname;
366 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
367 my $pdf_filename = $form->generate_attachment_filename();
370 my @errors = $self->generate_pdf(\$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 if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) {
479 my @errors = $self->generate_pdf(\$pdf, {media => $::form->{media},
480 format => $::form->{print_options}->{format},
481 formname => $::form->{print_options}->{formname},
482 language => $self->order->language,
483 printer_id => $::form->{print_options}->{printer_id},
484 groupitems => $::form->{print_options}->{groupitems}});
485 if (scalar @errors) {
486 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
489 my @warnings = $self->store_pdf_to_webdav_and_filemanagement($pdf, $::form->{attachment_filename});
490 if (scalar @warnings) {
491 flash_later('warning', $_) for @warnings;
494 my $sfile = SL::SessionFile::Random->new(mode => "w");
495 $sfile->fh->print($pdf);
498 $::form->{tmpfile} = $sfile->file_name;
499 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
502 $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a linked record to the mail
503 $::form->send_email(\%::myconfig, 'pdf');
506 my $intnotes = $self->order->intnotes;
507 $intnotes .= "\n\n" if $self->order->intnotes;
508 $intnotes .= t8('[email]') . "\n";
509 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
510 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
511 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
512 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
513 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
514 $intnotes .= t8('Message') . ": " . $::form->{message};
516 $self->order->update_attributes(intnotes => $intnotes);
518 $self->save_history('MAILED');
520 flash_later('info', t8('The email has been sent.'));
522 my @redirect_params = (
525 id => $self->order->id,
528 $self->redirect_to(@redirect_params);
531 # open the periodic invoices config dialog
533 # If there are values in the form (i.e. dialog was opened before),
534 # then use this values. Create new ones, else.
535 sub action_show_periodic_invoices_config_dialog {
538 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
539 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
540 $config ||= SL::DB::PeriodicInvoicesConfig->new(periodicity => 'm',
541 order_value_periodicity => 'p', # = same as periodicity
542 start_date_as_date => $::form->{transdate_as_date} || $::form->current_date,
543 extend_automatically_by => 12,
545 email_subject => GenericTranslations->get(
546 language_id => $::form->{language_id},
547 translation_type =>"preset_text_periodic_invoices_email_subject"),
548 email_body => GenericTranslations->get(
549 language_id => $::form->{language_id},
550 translation_type =>"preset_text_periodic_invoices_email_body"),
552 $config->periodicity('m') if none { $_ eq $config->periodicity } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
553 $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
555 $::form->get_lists(printers => "ALL_PRINTERS",
556 charts => { key => 'ALL_CHARTS',
557 transdate => 'current_date' });
559 $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
561 if ($::form->{customer_id}) {
562 $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
563 my $customer_object = SL::DB::Manager::Customer->find_by(id => $::form->{customer_id});
564 $::form->{postal_invoice} = $customer_object->postal_invoice;
565 $::form->{email_recipient_invoice_address} = $::form->{postal_invoice} ? '' : $customer_object->invoice_mail;
566 $config->send_email(0) if $::form->{postal_invoice};
569 $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
571 popup_js_close_function => 'kivi.Order.close_periodic_invoices_config_dialog()',
572 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
577 # assign the values of the periodic invoices config dialog
578 # as yaml in the hidden tag and set the status.
579 sub action_assign_periodic_invoices_config {
582 $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
584 my $config = { active => $::form->{active} ? 1 : 0,
585 terminated => $::form->{terminated} ? 1 : 0,
586 direct_debit => $::form->{direct_debit} ? 1 : 0,
587 periodicity => (any { $_ eq $::form->{periodicity} } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES) ? $::form->{periodicity} : 'm',
588 order_value_periodicity => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
589 start_date_as_date => $::form->{start_date_as_date},
590 end_date_as_date => $::form->{end_date_as_date},
591 first_billing_date_as_date => $::form->{first_billing_date_as_date},
592 print => $::form->{print} ? 1 : 0,
593 printer_id => $::form->{print} ? $::form->{printer_id} * 1 : undef,
594 copies => $::form->{copies} * 1 ? $::form->{copies} : 1,
595 extend_automatically_by => $::form->{extend_automatically_by} * 1 || undef,
596 ar_chart_id => $::form->{ar_chart_id} * 1,
597 send_email => $::form->{send_email} ? 1 : 0,
598 email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
599 email_recipient_address => $::form->{email_recipient_address},
600 email_sender => $::form->{email_sender},
601 email_subject => $::form->{email_subject},
602 email_body => $::form->{email_body},
605 my $periodic_invoices_config = SL::YAML::Dump($config);
607 my $status = $self->get_periodic_invoices_status($config);
610 ->remove('#order_periodic_invoices_config')
611 ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
612 ->run('kivi.Order.close_periodic_invoices_config_dialog')
613 ->html('#periodic_invoices_status', $status)
614 ->flash('info', t8('The periodic invoices config has been assigned.'))
618 sub action_get_has_active_periodic_invoices {
621 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
622 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
624 my $has_active_periodic_invoices =
625 $self->type eq sales_order_type()
628 && (!$config->end_date || ($config->end_date > DateTime->today_local))
629 && $config->get_previous_billed_period_start_date;
631 $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
634 # save the order and redirect to the frontend subroutine for a new
636 sub action_save_and_delivery_order {
639 $self->save_and_redirect_to(
640 controller => 'oe.pl',
641 action => 'oe_delivery_order_from_order',
645 # save the order and redirect to the frontend subroutine for a new
647 sub action_save_and_invoice {
650 $self->save_and_redirect_to(
651 controller => 'oe.pl',
652 action => 'oe_invoice_from_order',
656 # workflow from sales order to sales quotation
657 sub action_sales_quotation {
658 $_[0]->workflow_sales_or_request_for_quotation();
661 # workflow from sales order to sales quotation
662 sub action_request_for_quotation {
663 $_[0]->workflow_sales_or_request_for_quotation();
666 # workflow from sales quotation to sales order
667 sub action_sales_order {
668 $_[0]->workflow_sales_or_purchase_order();
671 # workflow from rfq to purchase order
672 sub action_purchase_order {
673 $_[0]->workflow_sales_or_purchase_order();
676 # workflow from purchase order to ap transaction
677 sub action_save_and_ap_transaction {
680 $self->save_and_redirect_to(
681 controller => 'ap.pl',
682 action => 'add_from_purchase_order',
686 # set form elements in respect to a changed customer or vendor
688 # This action is called on an change of the customer/vendor picker.
689 sub action_customer_vendor_changed {
692 setup_order_from_cv($self->order);
695 my $cv_method = $self->cv;
697 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
698 $self->js->show('#cp_row');
700 $self->js->hide('#cp_row');
703 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
704 $self->js->show('#shipto_selection');
706 $self->js->hide('#shipto_selection');
709 if ($cv_method eq 'customer') {
710 my $show_hide = scalar @{ $self->order->customer->additional_billing_addresses } > 0 ? 'show' : 'hide';
711 $self->js->$show_hide('#billing_address_row');
714 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
717 ->replaceWith('#order_cp_id', $self->build_contact_select)
718 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
719 ->replaceWith('#shipto_inputs ', $self->build_shipto_inputs)
720 ->replaceWith('#order_billing_address_id', $self->build_billing_address_select)
721 ->replaceWith('#business_info_row', $self->build_business_info_row)
722 ->val( '#order_taxzone_id', $self->order->taxzone_id)
723 ->val( '#order_taxincluded', $self->order->taxincluded)
724 ->val( '#order_currency_id', $self->order->currency_id)
725 ->val( '#order_payment_id', $self->order->payment_id)
726 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
727 ->val( '#order_intnotes', $self->order->intnotes)
728 ->val( '#order_language_id', $self->order->$cv_method->language_id)
729 ->focus( '#order_' . $self->cv . '_id')
730 ->run('kivi.Order.update_exchangerate');
732 $self->js_redisplay_amounts_and_taxes;
733 $self->js_redisplay_cvpartnumbers;
737 # open the dialog for customer/vendor details
738 sub action_show_customer_vendor_details_dialog {
741 my $is_customer = 'customer' eq $::form->{vc};
744 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
746 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
749 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
750 $details{discount_as_percent} = $cv->discount_as_percent;
751 $details{creditlimt} = $cv->creditlimit_as_number;
752 $details{business} = $cv->business->description if $cv->business;
753 $details{language} = $cv->language_obj->description if $cv->language_obj;
754 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
755 $details{payment_terms} = $cv->payment->description if $cv->payment;
756 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
758 foreach my $entry (@{ $cv->additional_billing_addresses }) {
759 push @{ $details{ADDITIONAL_BILLING_ADDRESSES} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
761 foreach my $entry (@{ $cv->shipto }) {
762 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
764 foreach my $entry (@{ $cv->contacts }) {
765 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
768 $_[0]->render('common/show_vc_details', { layout => 0 },
769 is_customer => $is_customer,
774 # called if a unit in an existing item row is changed
775 sub action_unit_changed {
778 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
779 my $item = $self->order->items_sorted->[$idx];
781 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
782 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
787 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
788 $self->js_redisplay_line_values;
789 $self->js_redisplay_amounts_and_taxes;
793 # add an item row for a new item entered in the input row
794 sub action_add_item {
797 delete $::form->{add_item}->{create_part_type};
799 my $form_attr = $::form->{add_item};
801 return unless $form_attr->{parts_id};
803 my $item = new_item($self->order, $form_attr);
805 $self->order->add_items($item);
809 $self->get_item_cvpartnumber($item);
811 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
812 my $row_as_html = $self->p->render('order/tabs/_row',
818 if ($::form->{insert_before_item_id}) {
820 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
823 ->append('#row_table_id', $row_as_html);
826 if ( $item->part->is_assortment ) {
827 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
828 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
829 my $attr = { parts_id => $assortment_item->parts_id,
830 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
831 unit => $assortment_item->unit,
832 description => $assortment_item->part->description,
834 my $item = new_item($self->order, $attr);
836 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
837 $item->discount(1) unless $assortment_item->charge;
839 $self->order->add_items( $item );
841 $self->get_item_cvpartnumber($item);
842 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
843 my $row_as_html = $self->p->render('order/tabs/_row',
848 if ($::form->{insert_before_item_id}) {
850 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
853 ->append('#row_table_id', $row_as_html);
859 ->val('.add_item_input', '')
860 ->run('kivi.Order.init_row_handlers')
861 ->run('kivi.Order.renumber_positions')
862 ->focus('#add_item_parts_id_name');
864 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
866 $self->js_redisplay_amounts_and_taxes;
870 # add item rows for multiple items at once
871 sub action_add_multi_items {
874 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
875 return $self->js->render() unless scalar @form_attr;
878 foreach my $attr (@form_attr) {
879 my $item = new_item($self->order, $attr);
881 if ( $item->part->is_assortment ) {
882 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
883 my $attr = { parts_id => $assortment_item->parts_id,
884 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
885 unit => $assortment_item->unit,
886 description => $assortment_item->part->description,
888 my $item = new_item($self->order, $attr);
890 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
891 $item->discount(1) unless $assortment_item->charge;
896 $self->order->add_items(@items);
900 foreach my $item (@items) {
901 $self->get_item_cvpartnumber($item);
902 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
903 my $row_as_html = $self->p->render('order/tabs/_row',
909 if ($::form->{insert_before_item_id}) {
911 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
914 ->append('#row_table_id', $row_as_html);
919 ->run('kivi.Part.close_picker_dialogs')
920 ->run('kivi.Order.init_row_handlers')
921 ->run('kivi.Order.renumber_positions')
922 ->focus('#add_item_parts_id_name');
924 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
926 $self->js_redisplay_amounts_and_taxes;
930 # recalculate all linetotals, amounts and taxes and redisplay them
931 sub action_recalc_amounts_and_taxes {
936 $self->js_redisplay_line_values;
937 $self->js_redisplay_amounts_and_taxes;
941 sub action_update_exchangerate {
945 is_standard => $self->order->currency_id == $::instance_conf->get_currency_id,
946 currency_name => $self->order->currency->name,
947 exchangerate => $self->order->daily_exchangerate_as_null_number,
950 $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
953 # redisplay item rows if they are sorted by an attribute
954 sub action_reorder_items {
958 partnumber => sub { $_[0]->part->partnumber },
959 description => sub { $_[0]->description },
960 qty => sub { $_[0]->qty },
961 sellprice => sub { $_[0]->sellprice },
962 discount => sub { $_[0]->discount },
963 cvpartnumber => sub { $_[0]->{cvpartnumber} },
966 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
968 my $method = $sort_keys{$::form->{order_by}};
969 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
970 if ($::form->{sort_dir}) {
971 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
972 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
974 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
977 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
978 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
980 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
984 ->run('kivi.Order.redisplay_items', \@to_sort)
988 # show the popup to choose a price/discount source
989 sub action_price_popup {
992 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
993 my $item = $self->order->items_sorted->[$idx];
995 $self->render_price_dialog($item);
998 # save the order in a session variable and redirect to the part controller
999 sub action_create_part {
1002 my $previousform = $::auth->save_form_in_session(non_scalars => 1);
1004 my $callback = $self->url_for(
1005 action => 'return_from_create_part',
1006 type => $self->type, # type is needed for check_auth on return
1007 previousform => $previousform,
1010 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.'));
1012 my @redirect_params = (
1013 controller => 'Part',
1015 part_type => $::form->{add_item}->{create_part_type},
1016 callback => $callback,
1020 $self->redirect_to(@redirect_params);
1023 sub action_return_from_create_part {
1026 $self->{created_part} = SL::DB::Part->new(id => delete $::form->{new_parts_id})->load if $::form->{new_parts_id};
1028 $::auth->restore_form_from_session(delete $::form->{previousform});
1030 # set item ids to new fake id, to identify them as new items
1031 foreach my $item (@{$self->order->items_sorted}) {
1032 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1036 $self->get_unalterable_data();
1037 $self->pre_render();
1039 # trigger rendering values for second row/longdescription as hidden,
1040 # because they are loaded only on demand. So we need to keep the values
1042 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1043 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
1047 title => $self->get_title_for('edit'),
1048 %{$self->{template_args}}
1053 # load the second row for one or more items
1055 # This action gets the html code for all items second rows by rendering a template for
1056 # the second row and sets the html code via client js.
1057 sub action_load_second_rows {
1060 $self->recalc() if $self->order->is_sales; # for margin calculation
1062 foreach my $item_id (@{ $::form->{item_ids} }) {
1063 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1064 my $item = $self->order->items_sorted->[$idx];
1066 $self->js_load_second_row($item, $item_id, 0);
1069 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
1071 $self->js->render();
1074 # update description, notes and sellprice from master data
1075 sub action_update_row_from_master_data {
1078 foreach my $item_id (@{ $::form->{item_ids} }) {
1079 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1080 my $item = $self->order->items_sorted->[$idx];
1081 my $texts = get_part_texts($item->part, $self->order->language_id);
1083 $item->description($texts->{description});
1084 $item->longdescription($texts->{longdescription});
1086 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1089 if ($item->part->is_assortment) {
1090 # add assortment items with price 0, as the components carry the price
1091 $price_src = $price_source->price_from_source("");
1092 $price_src->price(0);
1094 $price_src = $price_source->best_price
1095 ? $price_source->best_price
1096 : $price_source->price_from_source("");
1097 $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate;
1098 $price_src->price(0) if !$price_source->best_price;
1102 $item->sellprice($price_src->price);
1103 $item->active_price_source($price_src);
1106 ->run('kivi.Order.update_sellprice', $item_id, $item->sellprice_as_number)
1107 ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
1108 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
1109 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
1111 if ($self->search_cvpartnumber) {
1112 $self->get_item_cvpartnumber($item);
1113 $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
1118 $self->js_redisplay_line_values;
1119 $self->js_redisplay_amounts_and_taxes;
1121 $self->js->render();
1124 sub js_load_second_row {
1125 my ($self, $item, $item_id, $do_parse) = @_;
1128 # Parse values from form (they are formated while rendering (template)).
1129 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1130 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1131 foreach my $var (@{ $item->cvars_by_config }) {
1132 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1134 $item->parse_custom_variable_values;
1137 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1140 ->html('#second_row_' . $item_id, $row_as_html)
1141 ->data('#second_row_' . $item_id, 'loaded', 1);
1144 sub js_redisplay_line_values {
1147 my $is_sales = $self->order->is_sales;
1149 # sales orders with margins
1154 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1155 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1156 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1157 ]} @{ $self->order->items_sorted };
1161 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1162 ]} @{ $self->order->items_sorted };
1166 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1169 sub js_redisplay_amounts_and_taxes {
1172 if (scalar @{ $self->{taxes} }) {
1173 $self->js->show('#taxincluded_row_id');
1175 $self->js->hide('#taxincluded_row_id');
1178 if ($self->order->taxincluded) {
1179 $self->js->hide('#subtotal_row_id');
1181 $self->js->show('#subtotal_row_id');
1184 if ($self->order->is_sales) {
1185 my $is_neg = $self->order->marge_total < 0;
1187 ->html('#marge_total_id', $::form->format_amount(\%::myconfig, $self->order->marge_total, 2))
1188 ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
1189 ->action_if( $is_neg, 'addClass', '#marge_total_id', 'plus0')
1190 ->action_if( $is_neg, 'addClass', '#marge_percent_id', 'plus0')
1191 ->action_if( $is_neg, 'addClass', '#marge_percent_sign_id', 'plus0')
1192 ->action_if(!$is_neg, 'removeClass', '#marge_total_id', 'plus0')
1193 ->action_if(!$is_neg, 'removeClass', '#marge_percent_id', 'plus0')
1194 ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
1198 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
1199 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
1200 ->remove('.tax_row')
1201 ->insertBefore($self->build_tax_rows, '#amount_row_id');
1204 sub js_redisplay_cvpartnumbers {
1207 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1209 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1212 ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
1215 sub js_reset_order_and_item_ids_after_save {
1219 ->val('#id', $self->order->id)
1220 ->val('#converted_from_oe_id', '')
1221 ->val('#order_' . $self->nr_key(), $self->order->number);
1224 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1225 next if !$self->order->items_sorted->[$idx]->id;
1226 next if $form_item_id !~ m{^new};
1228 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1229 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1230 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1234 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1241 sub init_valid_types {
1242 [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
1248 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1249 die "Not a valid type for order";
1252 $self->type($::form->{type});
1258 my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer'
1259 : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
1260 : die "Not a valid type for order";
1265 sub init_search_cvpartnumber {
1268 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1269 my $search_cvpartnumber;
1270 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1271 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1273 return $search_cvpartnumber;
1276 sub init_show_update_button {
1279 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1290 sub init_all_price_factors {
1291 SL::DB::Manager::PriceFactor->get_all;
1294 sub init_part_picker_classification_ids {
1296 my $attribute = 'used_for_' . ($self->type =~ m{sales} ? 'sale' : 'purchase');
1298 return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => [ $attribute => 1 ]) } ];
1304 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
1306 my $right = $right_for->{ $self->type };
1307 $right ||= 'DOES_NOT_EXIST';
1309 $::auth->assert($right);
1312 # build the selection box for contacts
1314 # Needed, if customer/vendor changed.
1315 sub build_contact_select {
1318 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1319 value_key => 'cp_id',
1320 title_key => 'full_name_dep',
1321 default => $self->order->cp_id,
1323 style => 'width: 300px',
1327 # build the selection box for the additional billing address
1329 # Needed, if customer/vendor changed.
1330 sub build_billing_address_select {
1333 select_tag('order.billing_address_id',
1334 [ {displayable_id => '', id => ''}, $self->order->{$self->cv}->additional_billing_addresses ],
1336 title_key => 'displayable_id',
1337 default => $self->order->billing_address_id,
1339 style => 'width: 300px',
1343 # build the selection box for shiptos
1345 # Needed, if customer/vendor changed.
1346 sub build_shipto_select {
1349 select_tag('order.shipto_id',
1350 [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
1351 value_key => 'shipto_id',
1352 title_key => 'displayable_id',
1353 default => $self->order->shipto_id,
1355 style => 'width: 300px',
1359 # build the inputs for the cusom shipto dialog
1361 # Needed, if customer/vendor changed.
1362 sub build_shipto_inputs {
1365 my $content = $self->p->render('common/_ship_to_dialog',
1366 vc_obj => $self->order->customervendor,
1367 cs_obj => $self->order->custom_shipto,
1368 cvars => $self->order->custom_shipto->cvars_by_config,
1369 id_selector => '#order_shipto_id');
1371 div_tag($content, id => 'shipto_inputs');
1374 # render the info line for business
1376 # Needed, if customer/vendor changed.
1377 sub build_business_info_row
1379 $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
1382 # build the rows for displaying taxes
1384 # Called if amounts where recalculated and redisplayed.
1385 sub build_tax_rows {
1389 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
1390 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
1392 return $rows_as_html;
1396 sub render_price_dialog {
1397 my ($self, $record_item) = @_;
1399 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1403 'kivi.io.price_chooser_dialog',
1404 t8('Available Prices'),
1405 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1410 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1411 # $self->js->show('#dialog_flash_error');
1420 return if !$::form->{id};
1422 $self->order(SL::DB::Order->new(id => $::form->{id})->load);
1424 # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
1425 # You need a custom shipto object to call cvars_by_config to get the cvars.
1426 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
1428 return $self->order;
1431 # load or create a new order object
1433 # And assign changes from the form to this object.
1434 # If the order is loaded from db, check if items are deleted in the form,
1435 # remove them form the object and collect them for removing from db on saving.
1436 # Then create/update items from form (via make_item) and add them.
1440 # add_items adds items to an order with no items for saving, but they cannot
1441 # be retrieved via items until the order is saved. Adding empty items to new
1442 # order here solves this problem.
1444 $order = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1445 $order ||= SL::DB::Order->new(orderitems => [],
1446 quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())),
1447 currency_id => $::instance_conf->get_currency_id(),);
1449 my $cv_id_method = $self->cv . '_id';
1450 if (!$::form->{id} && $::form->{$cv_id_method}) {
1451 $order->$cv_id_method($::form->{$cv_id_method});
1452 setup_order_from_cv($order);
1455 my $form_orderitems = delete $::form->{order}->{orderitems};
1456 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1458 $order->assign_attributes(%{$::form->{order}});
1460 $self->setup_custom_shipto_from_form($order, $::form);
1462 if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) {
1463 my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
1464 $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
1467 # remove deleted items
1468 $self->item_ids_to_delete([]);
1469 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1470 my $item = $order->orderitems->[$idx];
1471 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1472 splice @{$order->orderitems}, $idx, 1;
1473 push @{$self->item_ids_to_delete}, $item->id;
1479 foreach my $form_attr (@{$form_orderitems}) {
1480 my $item = make_item($order, $form_attr);
1481 $item->position($pos);
1485 $order->add_items(grep {!$_->id} @items);
1490 # create or update items from form
1492 # Make item objects from form values. For items already existing read from db.
1493 # Create a new item else. And assign attributes.
1495 my ($record, $attr) = @_;
1498 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1500 my $is_new = !$item;
1502 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1503 # they cannot be retrieved via custom_variables until the order/orderitem is
1504 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1505 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1507 $item->assign_attributes(%$attr);
1510 my $texts = get_part_texts($item->part, $record->language_id);
1511 $item->longdescription($texts->{longdescription}) if !defined $attr->{longdescription};
1512 $item->project_id($record->globalproject_id) if !defined $attr->{project_id};
1513 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number};
1521 # This is used to add one item
1523 my ($record, $attr) = @_;
1525 my $item = SL::DB::OrderItem->new;
1527 # Remove attributes where the user left or set the inputs empty.
1528 # So these attributes will be undefined and we can distinguish them
1529 # from zero later on.
1530 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1531 delete $attr->{$_} if $attr->{$_} eq '';
1534 $item->assign_attributes(%$attr);
1536 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1537 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1539 $item->unit($part->unit) if !$item->unit;
1542 if ( $part->is_assortment ) {
1543 # add assortment items with price 0, as the components carry the price
1544 $price_src = $price_source->price_from_source("");
1545 $price_src->price(0);
1546 } elsif (defined $item->sellprice) {
1547 $price_src = $price_source->price_from_source("");
1548 $price_src->price($item->sellprice);
1550 $price_src = $price_source->best_price
1551 ? $price_source->best_price
1552 : $price_source->price_from_source("");
1553 $price_src->price($::form->round_amount($price_src->price / $record->exchangerate, 5)) if $record->exchangerate;
1554 $price_src->price(0) if !$price_source->best_price;
1558 if (defined $item->discount) {
1559 $discount_src = $price_source->discount_from_source("");
1560 $discount_src->discount($item->discount);
1562 $discount_src = $price_source->best_discount
1563 ? $price_source->best_discount
1564 : $price_source->discount_from_source("");
1565 $discount_src->discount(0) if !$price_source->best_discount;
1569 $new_attr{part} = $part;
1570 $new_attr{description} = $part->description if ! $item->description;
1571 $new_attr{qty} = 1.0 if ! $item->qty;
1572 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1573 $new_attr{sellprice} = $price_src->price;
1574 $new_attr{discount} = $discount_src->discount;
1575 $new_attr{active_price_source} = $price_src;
1576 $new_attr{active_discount_source} = $discount_src;
1577 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1578 $new_attr{project_id} = $record->globalproject_id;
1579 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1581 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1582 # they cannot be retrieved via custom_variables until the order/orderitem is
1583 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1584 $new_attr{custom_variables} = [];
1586 my $texts = get_part_texts($part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription});
1588 $item->assign_attributes(%new_attr, %{ $texts });
1593 sub setup_order_from_cv {
1596 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id));
1598 $order->intnotes($order->customervendor->notes);
1600 return if !$order->is_sales;
1602 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1603 $order->taxincluded(defined($order->customer->taxincluded_checked)
1604 ? $order->customer->taxincluded_checked
1605 : $::myconfig{taxincluded_checked});
1607 my $address = $order->customer->default_billing_address;;
1608 $order->billing_address_id($address ? $address->id : undef);
1611 # setup custom shipto from form
1613 # The dialog returns form variables starting with 'shipto' and cvars starting
1614 # with 'shiptocvar_'.
1615 # Mark it to be deleted if a shipto from master data is selected
1616 # (i.e. order has a shipto).
1617 # Else, update or create a new custom shipto. If the fields are empty, it
1618 # will not be saved on save.
1619 sub setup_custom_shipto_from_form {
1620 my ($self, $order, $form) = @_;
1622 if ($order->shipto) {
1623 $self->is_custom_shipto_to_delete(1);
1625 my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1627 my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
1628 my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
1630 $custom_shipto->assign_attributes(%$shipto_attrs);
1631 $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
1635 # recalculate prices and taxes
1637 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1641 my %pat = $self->order->calculate_prices_and_taxes();
1643 $self->{taxes} = [];
1644 foreach my $tax_id (keys %{ $pat{taxes_by_tax_id} }) {
1645 my $netamount = sum0 map { $pat{amounts}->{$_}->{amount} } grep { $pat{amounts}->{$_}->{tax_id} == $tax_id } keys %{ $pat{amounts} };
1647 push(@{ $self->{taxes} }, { amount => $pat{taxes_by_tax_id}->{$tax_id},
1648 netamount => $netamount,
1649 tax => SL::DB::Tax->new(id => $tax_id)->load });
1651 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items_sorted}, @{$pat{items}};
1654 # get data for saving, printing, ..., that is not changed in the form
1656 # Only cvars for now.
1657 sub get_unalterable_data {
1660 foreach my $item (@{ $self->order->items }) {
1661 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1662 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1663 foreach my $var (@{ $item->cvars_by_config }) {
1664 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1666 $item->parse_custom_variable_values;
1672 # And remove related files in the spool directory
1677 my $db = $self->order->db;
1679 $db->with_transaction(
1681 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1682 $self->order->delete;
1683 my $spool = $::lx_office_conf{paths}->{spool};
1684 unlink map { "$spool/$_" } @spoolfiles if $spool;
1686 $self->save_history('DELETED');
1689 }) || push(@{$errors}, $db->error);
1696 # And delete items that are deleted in the form.
1701 my $db = $self->order->db;
1703 $db->with_transaction(sub {
1704 # delete custom shipto if it is to be deleted or if it is empty
1705 if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
1706 $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
1707 $self->order->custom_shipto(undef);
1710 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1711 $self->order->save(cascade => 1);
1714 if ($::form->{converted_from_oe_id}) {
1715 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1717 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1718 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1719 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
1720 $src->link_to_record($self->order);
1722 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1724 foreach (@{ $self->order->items_sorted }) {
1725 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1727 SL::DB::RecordLink->new(from_table => 'orderitems',
1728 from_id => $from_id,
1729 to_table => 'orderitems',
1736 $self->link_requirement_specs_linking_to_created_from_objects(@converted_from_oe_ids);
1739 $self->set_project_in_linked_requirement_specs if $self->order->globalproject_id;
1741 $self->save_history('SAVED');
1744 }) || push(@{$errors}, $db->error);
1749 sub workflow_sales_or_request_for_quotation {
1753 my $errors = $self->save();
1755 if (scalar @{ $errors }) {
1756 $self->js->flash('error', $_) for @{ $errors };
1757 return $self->js->render();
1760 my $destination_type = $::form->{type} eq sales_order_type() ? sales_quotation_type() : request_quotation_type();
1762 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1763 $self->{converted_from_oe_id} = delete $::form->{id};
1765 # set item ids to new fake id, to identify them as new items
1766 foreach my $item (@{$self->order->items_sorted}) {
1767 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1771 $::form->{type} = $destination_type;
1772 $self->type($self->init_type);
1773 $self->cv ($self->init_cv);
1777 $self->get_unalterable_data();
1778 $self->pre_render();
1780 # trigger rendering values for second row as hidden, because they
1781 # are loaded only on demand. So we need to keep the values from the
1783 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1787 title => $self->get_title_for('edit'),
1788 %{$self->{template_args}}
1792 sub workflow_sales_or_purchase_order {
1796 my $errors = $self->save();
1798 if (scalar @{ $errors }) {
1799 $self->js->flash('error', $_) foreach @{ $errors };
1800 return $self->js->render();
1803 my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type()
1804 : $::form->{type} eq request_quotation_type() ? purchase_order_type()
1805 : $::form->{type} eq purchase_order_type() ? sales_order_type()
1806 : $::form->{type} eq sales_order_type() ? purchase_order_type()
1809 # check for direct delivery
1810 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
1812 if ( $::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()
1813 && $::form->{use_shipto} && $self->order->shipto) {
1814 $custom_shipto = $self->order->shipto->clone('SL::DB::Order');
1817 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1818 $self->{converted_from_oe_id} = delete $::form->{id};
1820 # set item ids to new fake id, to identify them as new items
1821 foreach my $item (@{$self->order->items_sorted}) {
1822 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1825 if ($::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()) {
1826 if ($::form->{use_shipto}) {
1827 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
1829 # remove any custom shipto if not wanted
1830 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1835 $::form->{type} = $destination_type;
1836 $self->type($self->init_type);
1837 $self->cv ($self->init_cv);
1841 $self->get_unalterable_data();
1842 $self->pre_render();
1844 # trigger rendering values for second row as hidden, because they
1845 # are loaded only on demand. So we need to keep the values from the
1847 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1851 title => $self->get_title_for('edit'),
1852 %{$self->{template_args}}
1860 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1861 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
1862 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1863 $self->{all_languages} = SL::DB::Manager::Language->get_all_sorted();
1864 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1867 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1870 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1872 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1873 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1874 $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
1875 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1876 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1878 my $print_form = Form->new('');
1879 $print_form->{type} = $self->type;
1880 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1881 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1882 form => $print_form,
1883 options => {dialog_name_prefix => 'print_options.',
1887 no_opendocument => 0,
1891 foreach my $item (@{$self->order->orderitems}) {
1892 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1893 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1894 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1897 if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
1898 # Calculate shipped qtys here to prevent calling calculate for every item via the items method.
1899 # Do not use write_to_objects to prevent order->delivered to be set, because this should be
1900 # the value from db, which can be set manually or is set when linked delivery orders are saved.
1901 SL::Helper::ShippedQty->new->calculate($self->order)->write_to(\@{$self->order->items});
1904 if ($self->order->number && $::instance_conf->get_webdav) {
1905 my $webdav = SL::Webdav->new(
1906 type => $self->type,
1907 number => $self->order->number,
1909 my @all_objects = $webdav->get_all_objects;
1910 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1912 link => File::Spec->catfile($_->full_filedescriptor),
1916 if ( (any { $self->type eq $_ } (sales_quotation_type(), sales_order_type()))
1917 && $::instance_conf->get_transport_cost_reminder_article_number_id ) {
1918 $self->{template_args}->{transport_cost_reminder_article} = SL::DB::Part->new(id => $::instance_conf->get_transport_cost_reminder_article_number_id)->load;
1921 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1923 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
1924 edit_periodic_invoices_config calculate_qty kivi.Validator follow_up show_history);
1925 $self->setup_edit_action_bar;
1928 sub setup_edit_action_bar {
1929 my ($self, %params) = @_;
1931 my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
1932 || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
1933 || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
1935 my @req_trans_cost_art = qw(kivi.Order.check_transport_cost_article_presence) x!!$::instance_conf->get_transport_cost_reminder_article_number_id;
1936 my @req_cusordnumber = qw(kivi.Order.check_cusordnumber_presence) x($self->type eq sales_order_type() && $::instance_conf->get_order_warn_no_cusordnumber);
1938 for my $bar ($::request->layout->get('actionbar')) {
1943 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1944 $::instance_conf->get_order_warn_no_deliverydate,
1946 checks => [ 'kivi.Order.check_save_active_periodic_invoices', ['kivi.validate_form','#order_form'],
1947 @req_trans_cost_art, @req_cusordnumber,
1952 call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1953 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
1954 @req_trans_cost_art, @req_cusordnumber,
1956 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1958 ], # end of combobox "Save"
1965 t8('Save and Quotation'),
1966 submit => [ '#order_form', { action => "Order/sales_quotation" } ],
1967 checks => [ @req_trans_cost_art, @req_cusordnumber ],
1968 only_if => (any { $self->type eq $_ } (sales_order_type())),
1972 submit => [ '#order_form', { action => "Order/request_for_quotation" } ],
1973 only_if => (any { $self->type eq $_ } (purchase_order_type())),
1976 t8('Save and Sales Order'),
1977 submit => [ '#order_form', { action => "Order/sales_order" } ],
1978 checks => [ @req_trans_cost_art ],
1979 only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
1982 t8('Save and Purchase Order'),
1983 call => [ 'kivi.Order.purchase_order_check_for_direct_delivery' ],
1984 checks => [ @req_trans_cost_art, @req_cusordnumber ],
1985 only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
1988 t8('Save and Delivery Order'),
1989 call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1990 $::instance_conf->get_order_warn_no_deliverydate,
1992 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
1993 @req_trans_cost_art, @req_cusordnumber,
1995 only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type()))
1998 t8('Save and Invoice'),
1999 call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
2000 checks => [ 'kivi.Order.check_save_active_periodic_invoices',
2001 @req_trans_cost_art, @req_cusordnumber,
2005 t8('Save and AP Transaction'),
2006 call => [ 'kivi.Order.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
2007 only_if => (any { $self->type eq $_ } (purchase_order_type()))
2010 ], # end of combobox "Workflow"
2017 t8('Save and preview PDF'),
2018 call => [ 'kivi.Order.save', 'preview_pdf', $::instance_conf->get_order_warn_duplicate_parts,
2019 $::instance_conf->get_order_warn_no_deliverydate,
2021 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2024 t8('Save and print'),
2025 call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts,
2026 $::instance_conf->get_order_warn_no_deliverydate,
2028 checks => [ @req_trans_cost_art, @req_cusordnumber ],
2031 t8('Save and E-mail'),
2032 id => 'save_and_email_action',
2033 call => [ 'kivi.Order.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts,
2034 $::instance_conf->get_order_warn_no_deliverydate,
2036 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
2039 t8('Download attachments of all parts'),
2040 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
2041 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
2042 only_if => $::instance_conf->get_doc_storage,
2044 ], # end of combobox "Export"
2048 call => [ 'kivi.Order.delete_order' ],
2049 confirm => $::locale->text('Do you really want to delete this object?'),
2050 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
2051 only_if => $deletion_allowed,
2060 call => [ 'set_history_window', $self->order->id, 'id' ],
2061 disabled => !$self->order->id ? t8('This record has not been saved yet.') : undef,
2065 call => [ 'kivi.Order.follow_up_window' ],
2066 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
2067 only_if => $::auth->assert('productivity', 1),
2069 ], # end of combobox "more"
2075 my ($self, $pdf_ref, $params) = @_;
2077 my $order = $self->order;
2080 my $print_form = Form->new('');
2081 $print_form->{type} = $order->type;
2082 $print_form->{formname} = $params->{formname} || $order->type;
2083 $print_form->{format} = $params->{format} || 'pdf';
2084 $print_form->{media} = $params->{media} || 'file';
2085 $print_form->{groupitems} = $params->{groupitems};
2086 $print_form->{printer_id} = $params->{printer_id};
2087 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
2089 $order->language($params->{language});
2090 $order->flatten_to_form($print_form, format_amounts => 1);
2094 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
2095 $template_ext = 'odt';
2096 $template_type = 'OpenDocument';
2099 # search for the template
2100 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
2101 name => $print_form->{formname},
2102 extension => $template_ext,
2103 email => $print_form->{media} eq 'email',
2104 language => $params->{language},
2105 printer_id => $print_form->{printer_id},
2108 if (!defined $template_file) {
2109 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);
2112 return @errors if scalar @errors;
2114 $print_form->throw_on_error(sub {
2116 $print_form->prepare_for_printing;
2118 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
2119 format => $print_form->{format},
2120 template_type => $template_type,
2121 template => $template_file,
2122 variables => $print_form,
2123 variable_content_types => {
2124 longdescription => 'html',
2125 partnotes => 'html',
2130 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
2136 sub get_files_for_email_dialog {
2139 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
2141 return %files if !$::instance_conf->get_doc_storage;
2143 if ($self->order->id) {
2144 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
2145 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
2146 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
2147 $files{project_files} = [ SL::File->get_all( object_id => $self->order->globalproject_id, object_type => 'project', file_type => 'attachment') ];
2151 uniq_by { $_->{id} }
2153 +{ id => $_->part->id,
2154 partnumber => $_->part->partnumber }
2155 } @{$self->order->items_sorted};
2157 foreach my $part (@parts) {
2158 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
2159 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
2162 foreach my $key (keys %files) {
2163 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
2169 sub make_periodic_invoices_config_from_yaml {
2170 my ($yaml_config) = @_;
2172 return if !$yaml_config;
2173 my $attr = SL::YAML::Load($yaml_config);
2174 return if 'HASH' ne ref $attr;
2175 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
2179 sub get_periodic_invoices_status {
2180 my ($self, $config) = @_;
2182 return if $self->type ne sales_order_type();
2183 return t8('not configured') if !$config;
2185 my $active = ('HASH' eq ref $config) ? $config->{active}
2186 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
2187 : die "Cannot get status of periodic invoices config";
2189 return $active ? t8('active') : t8('inactive');
2193 my ($self, $action) = @_;
2195 return '' if none { lc($action)} qw(add edit);
2198 # $::locale->text("Add Sales Order");
2199 # $::locale->text("Add Purchase Order");
2200 # $::locale->text("Add Quotation");
2201 # $::locale->text("Add Request for Quotation");
2202 # $::locale->text("Edit Sales Order");
2203 # $::locale->text("Edit Purchase Order");
2204 # $::locale->text("Edit Quotation");
2205 # $::locale->text("Edit Request for Quotation");
2207 $action = ucfirst(lc($action));
2208 return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order")
2209 : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order")
2210 : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation")
2211 : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
2215 sub get_item_cvpartnumber {
2216 my ($self, $item) = @_;
2218 return if !$self->search_cvpartnumber;
2219 return if !$self->order->customervendor;
2221 if ($self->cv eq 'vendor') {
2222 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
2223 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
2224 } elsif ($self->cv eq 'customer') {
2225 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
2226 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
2230 sub get_part_texts {
2231 my ($part_or_id, $language_or_id, %defaults) = @_;
2233 my $part = ref($part_or_id) ? $part_or_id : SL::DB::Part->load_cached($part_or_id);
2234 my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id;
2236 description => $defaults{description} // $part->description,
2237 longdescription => $defaults{longdescription} // $part->notes,
2240 return $texts unless $language_id;
2242 my $translation = SL::DB::Manager::Translation->get_first(
2244 parts_id => $part->id,
2245 language_id => $language_id,
2248 $texts->{description} = $translation->translation if $translation && $translation->translation;
2249 $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription;
2254 sub sales_order_type {
2258 sub purchase_order_type {
2262 sub sales_quotation_type {
2266 sub request_quotation_type {
2267 'request_quotation';
2271 return $_[0]->type eq sales_order_type() ? 'ordnumber'
2272 : $_[0]->type eq purchase_order_type() ? 'ordnumber'
2273 : $_[0]->type eq sales_quotation_type() ? 'quonumber'
2274 : $_[0]->type eq request_quotation_type() ? 'quonumber'
2278 sub save_and_redirect_to {
2279 my ($self, %params) = @_;
2281 my $errors = $self->save();
2283 if (scalar @{ $errors }) {
2284 $self->js->flash('error', $_) foreach @{ $errors };
2285 return $self->js->render();
2288 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
2289 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
2290 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
2291 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
2293 flash_later('info', $text);
2295 $self->redirect_to(%params, id => $self->order->id);
2299 my ($self, $addition) = @_;
2301 my $number_type = $self->order->type =~ m{order} ? 'ordnumber' : 'quonumber';
2302 my $snumbers = $number_type . '_' . $self->order->$number_type;
2304 SL::DB::History->new(
2305 trans_id => $self->order->id,
2306 employee_id => SL::DB::Manager::Employee->current->id,
2307 what_done => $self->order->type,
2308 snumbers => $snumbers,
2309 addition => $addition,
2313 sub store_pdf_to_webdav_and_filemanagement {
2314 my ($self, $content, $filename) = @_;
2316 my $order = $self->order;
2319 # copy file to webdav folder
2320 if ($order->number && $::instance_conf->get_webdav_documents) {
2321 my $webdav = SL::Webdav->new(
2322 type => $order->type,
2323 number => $order->number,
2325 my $webdav_file = SL::Webdav::File->new(
2327 filename => $filename,
2330 $webdav_file->store(data => \$content);
2333 push @errors, t8('Storing PDF to webdav folder failed: #1', $@);
2336 if ($order->id && $::instance_conf->get_doc_storage) {
2338 SL::File->save(object_id => $order->id,
2339 object_type => $order->type,
2340 mime_type => 'application/pdf',
2341 source => 'created',
2342 file_type => 'document',
2343 file_name => $filename,
2344 file_contents => $content);
2347 push @errors, t8('Storing PDF in storage backend failed: #1', $@);
2354 sub link_requirement_specs_linking_to_created_from_objects {
2355 my ($self, @converted_from_oe_ids) = @_;
2357 return unless @converted_from_oe_ids;
2359 my $rs_orders = SL::DB::Manager::RequirementSpecOrder->get_all(where => [ order_id => \@converted_from_oe_ids ]);
2360 foreach my $rs_order (@{ $rs_orders }) {
2361 SL::DB::RequirementSpecOrder->new(
2362 order_id => $self->order->id,
2363 requirement_spec_id => $rs_order->requirement_spec_id,
2364 version_id => $rs_order->version_id,
2369 sub set_project_in_linked_requirement_specs {
2372 my $rs_orders = SL::DB::Manager::RequirementSpecOrder->get_all(where => [ order_id => $self->order->id ]);
2373 foreach my $rs_order (@{ $rs_orders }) {
2374 next if $rs_order->requirement_spec->project_id == $self->order->globalproject_id;
2376 $rs_order->requirement_spec->update_attributes(project_id => $self->order->globalproject_id);
2388 SL::Controller::Order - controller for orders
2392 This is a new form to enter orders, completely rewritten with the use
2393 of controller and java script techniques.
2395 The aim is to provide the user a better experience and a faster workflow. Also
2396 the code should be more readable, more reliable and better to maintain.
2404 One input row, so that input happens every time at the same place.
2408 Use of pickers where possible.
2412 Possibility to enter more than one item at once.
2416 Item list in a scrollable area, so that the workflow buttons stay at
2421 Reordering item rows with drag and drop is possible. Sorting item rows is
2422 possible (by partnumber, description, qty, sellprice and discount for now).
2426 No C<update> is necessary. All entries and calculations are managed
2427 with ajax-calls and the page only reloads on C<save>.
2431 User can see changes immediately, because of the use of java script
2442 =item * C<SL/Controller/Order.pm>
2446 =item * C<template/webpages/order/form.html>
2450 =item * C<template/webpages/order/tabs/basic_data.html>
2452 Main tab for basic_data.
2454 This is the only tab here for now. "linked records" and "webdav" tabs are
2455 reused from generic code.
2459 =item * C<template/webpages/order/tabs/_business_info_row.html>
2461 For displaying information on business type
2463 =item * C<template/webpages/order/tabs/_item_input.html>
2465 The input line for items
2467 =item * C<template/webpages/order/tabs/_row.html>
2469 One row for already entered items
2471 =item * C<template/webpages/order/tabs/_tax_row.html>
2473 Displaying tax information
2475 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
2477 Dialog for selecting price and discount sources
2481 =item * C<js/kivi.Order.js>
2483 java script functions
2493 =item * price sources: little symbols showing better price / better discount
2495 =item * select units in input row?
2497 =item * check for direct delivery (workflow sales order -> purchase order)
2499 =item * access rights
2501 =item * display weights
2505 =item * optional client/user behaviour
2507 (transactions has to be set - department has to be set -
2508 force project if enabled in client config)
2512 =head1 KNOWN BUGS AND CAVEATS
2518 Customer discount is not displayed as a valid discount in price source popup
2519 (this might be a bug in price sources)
2521 (I cannot reproduce this (Bernd))
2525 No indication that <shift>-up/down expands/collapses second row.
2529 Inline creation of parts is not currently supported
2533 Table header is not sticky in the scrolling area.
2537 Sorting does not include C<position>, neither does reordering.
2539 This behavior was implemented intentionally. But we can discuss, which behavior
2540 should be implemented.
2544 =head1 To discuss / Nice to have
2550 How to expand/collapse second row. Now it can be done clicking the icon or
2555 Possibility to select PriceSources in input row?
2559 This controller uses a (changed) copy of the template for the PriceSource
2560 dialog. Maybe there could be used one code source.
2564 Rounding-differences between this controller (PriceTaxCalculator) and the old
2565 form. This is not only a problem here, but also in all parts using the PTC.
2566 There exists a ticket and a patch. This patch should be testet.
2570 An indicator, if the actual inputs are saved (like in an
2571 editor or on text processing application).
2575 A warning when leaving the page without saveing unchanged inputs.
2582 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>