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);
21 use SL::DB::PartClassification;
22 use SL::DB::PartsGroup;
25 use SL::DB::RecordLink;
27 use SL::DB::Translation;
29 use SL::Helper::CreatePDF qw(:all);
30 use SL::Helper::PrintOptions;
31 use SL::Helper::ShippedQty;
32 use SL::Helper::UserPreferences::PositionsScrollbar;
33 use SL::Helper::UserPreferences::UpdatePositions;
35 use SL::Controller::Helper::GetModels;
37 use List::Util qw(first sum0);
38 use List::UtilsBy qw(sort_by uniq_by);
39 use List::MoreUtils qw(any none pairwise first_index);
40 use English qw(-no_match_vars);
45 use Rose::Object::MakeMethods::Generic
47 scalar => [ qw(item_ids_to_delete is_custom_shipto_to_delete) ],
48 'scalar --get_set_init' => [ qw(order valid_types type cv p all_price_factors search_cvpartnumber show_update_button part_picker_classification_ids) ],
53 __PACKAGE__->run_before('check_auth');
55 __PACKAGE__->run_before('recalc',
56 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
59 __PACKAGE__->run_before('get_unalterable_data',
60 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
71 $self->order->transdate(DateTime->now_local());
72 my $extra_days = $self->{type} eq 'sales_quotation' ? $::instance_conf->get_reqdate_interval :
73 $self->{type} eq 'sales_order' ? $::instance_conf->get_delivery_date_interval : 1;
74 $self->order->reqdate(DateTime->today_local->next_workday(extra_days => $extra_days)) if !$self->order->reqdate;
80 title => $self->get_title_for('add'),
81 %{$self->{template_args}}
85 # edit an existing order
93 # this is to edit an order from an unsaved order object
95 # set item ids to new fake id, to identify them as new items
96 foreach my $item (@{$self->order->items_sorted}) {
97 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
99 # trigger rendering values for second row as hidden, because they
100 # are loaded only on demand. So we need to keep the values from
102 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
109 title => $self->get_title_for('edit'),
110 %{$self->{template_args}}
114 # edit a collective order (consisting of one or more existing orders)
115 sub action_edit_collective {
119 my @multi_ids = map {
120 $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1}
121 } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form;
123 # fall back to add if no ids are given
124 if (scalar @multi_ids == 0) {
129 # fall back to save as new if only one id is given
130 if (scalar @multi_ids == 1) {
131 $self->order(SL::DB::Order->new(id => $multi_ids[0])->load);
132 $self->action_save_as_new();
136 # make new order from given orders
137 my @multi_orders = map { SL::DB::Order->new(id => $_)->load } @multi_ids;
138 $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders;
139 $self->order(SL::DB::Order->new_from_multi(\@multi_orders, sort_sources_by => 'transdate'));
141 $self->action_edit();
148 my $errors = $self->delete();
150 if (scalar @{ $errors }) {
151 $self->js->flash('error', $_) foreach @{ $errors };
152 return $self->js->render();
155 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been deleted')
156 : $self->type eq purchase_order_type() ? $::locale->text('The order has been deleted')
157 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been deleted')
158 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been deleted')
160 flash_later('info', $text);
162 my @redirect_params = (
167 $self->redirect_to(@redirect_params);
174 my $errors = $self->save();
176 if (scalar @{ $errors }) {
177 $self->js->flash('error', $_) foreach @{ $errors };
178 return $self->js->render();
181 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
182 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
183 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
184 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
186 flash_later('info', $text);
188 my @redirect_params = (
191 id => $self->order->id,
194 $self->redirect_to(@redirect_params);
197 # save the order as new document an open it for edit
198 sub action_save_as_new {
201 my $order = $self->order;
204 $self->js->flash('error', t8('This object has not been saved yet.'));
205 return $self->js->render();
208 # load order from db to check if values changed
209 my $saved_order = SL::DB::Order->new(id => $order->id)->load;
212 # Lets assign a new number if the user hasn't changed the previous one.
213 # If it has been changed manually then use it as-is.
214 $new_attrs{number} = (trim($order->number) eq $saved_order->number)
216 : trim($order->number);
218 # Clear transdate unless changed
219 $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
220 ? DateTime->today_local
223 # Set new reqdate unless changed
224 if ($order->reqdate == $saved_order->reqdate) {
225 my $extra_days = $self->{type} eq 'sales_quotation' ? $::instance_conf->get_reqdate_interval :
226 $self->{type} eq 'sales_order' ? $::instance_conf->get_delivery_date_interval : 1;
227 $new_attrs{reqdate} = DateTime->today_local->next_workday(extra_days => $extra_days);
229 $new_attrs{reqdate} = $order->reqdate;
233 $new_attrs{employee} = SL::DB::Manager::Employee->current;
235 # Create new record from current one
236 $self->order(SL::DB::Order->new_from($order, destination_type => $order->type, attributes => \%new_attrs));
238 # no linked records on save as new
239 delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
242 $self->action_save();
247 # This is called if "print" is pressed in the print dialog.
248 # If PDF creation was requested and succeeded, the pdf is offered for download
249 # via send_file (which uses ajax in this case).
253 my $errors = $self->save();
255 if (scalar @{ $errors }) {
256 $self->js->flash('error', $_) foreach @{ $errors };
257 return $self->js->render();
260 $self->js_reset_order_and_item_ids_after_save;
262 my $format = $::form->{print_options}->{format};
263 my $media = $::form->{print_options}->{media};
264 my $formname = $::form->{print_options}->{formname};
265 my $copies = $::form->{print_options}->{copies};
266 my $groupitems = $::form->{print_options}->{groupitems};
267 my $printer_id = $::form->{print_options}->{printer_id};
269 # only pdf and opendocument by now
270 if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf)) {
271 return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
274 # only screen or printer by now
275 if (none { $media eq $_ } qw(screen printer)) {
276 return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
279 # create a form for generate_attachment_filename
280 my $form = Form->new;
281 $form->{$self->nr_key()} = $self->order->number;
282 $form->{type} = $self->type;
283 $form->{format} = $format;
284 $form->{formname} = $formname;
285 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
286 my $pdf_filename = $form->generate_attachment_filename();
289 my @errors = generate_pdf($self->order, \$pdf, { format => $format,
290 formname => $formname,
291 language => $self->order->language,
292 printer_id => $printer_id,
293 groupitems => $groupitems });
294 if (scalar @errors) {
295 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
298 if ($media eq 'screen') {
300 $self->js->flash('info', t8('The PDF has been created'));
303 type => SL::MIME->mime_type_from_ext($pdf_filename),
304 name => $pdf_filename,
308 } elsif ($media eq 'printer') {
310 my $printer_id = $::form->{print_options}->{printer_id};
311 SL::DB::Printer->new(id => $printer_id)->load->print_document(
316 $self->js->flash('info', t8('The PDF has been printed'));
319 # copy file to webdav folder
320 if ($self->order->number && $::instance_conf->get_webdav_documents) {
321 my $webdav = SL::Webdav->new(
323 number => $self->order->number,
325 my $webdav_file = SL::Webdav::File->new(
327 filename => $pdf_filename,
330 $webdav_file->store(data => \$pdf);
333 $self->js->flash('error', t8('Storing PDF to webdav folder failed: #1', $@));
336 if ($self->order->number && $::instance_conf->get_doc_storage) {
338 SL::File->save(object_id => $self->order->id,
339 object_type => $self->type,
340 mime_type => 'application/pdf',
342 file_type => 'document',
343 file_name => $pdf_filename,
344 file_contents => $pdf);
347 $self->js->flash('error', t8('Storing PDF in storage backend failed: #1', $@));
351 $self->save_history('PRINTED');
356 # open the email dialog
357 sub action_save_and_show_email_dialog {
360 my $errors = $self->save();
362 if (scalar @{ $errors }) {
363 $self->js->flash('error', $_) foreach @{ $errors };
364 return $self->js->render();
367 my $cv_method = $self->cv;
369 if (!$self->order->$cv_method) {
370 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'))
375 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
376 $email_form->{to} ||= $self->order->$cv_method->email;
377 $email_form->{cc} = $self->order->$cv_method->cc;
378 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
379 # Todo: get addresses from shipto, if any
381 my $form = Form->new;
382 $form->{$self->nr_key()} = $self->order->number;
383 $form->{cusordnumber} = $self->order->cusordnumber;
384 $form->{formname} = $self->type;
385 $form->{type} = $self->type;
386 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
387 $form->{language_id} = $self->order->language->id if $self->order->language;
388 $form->{format} = 'pdf';
390 $email_form->{subject} = $form->generate_email_subject();
391 $email_form->{attachment_filename} = $form->generate_attachment_filename();
392 $email_form->{message} = $form->generate_email_body();
393 $email_form->{js_send_function} = 'kivi.Order.send_email()';
395 my %files = $self->get_files_for_email_dialog();
396 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
397 email_form => $email_form,
398 show_bcc => $::auth->assert('email_bcc', 'may fail'),
400 is_customer => $self->cv eq 'customer',
404 ->run('kivi.Order.show_email_dialog', $dialog_html)
411 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
412 sub action_send_email {
415 my $errors = $self->save();
417 if (scalar @{ $errors }) {
418 $self->js->run('kivi.Order.close_email_dialog');
419 $self->js->flash('error', $_) foreach @{ $errors };
420 return $self->js->render();
423 $self->js_reset_order_and_item_ids_after_save;
425 my $email_form = delete $::form->{email_form};
426 my %field_names = (to => 'email');
428 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
430 # for Form::cleanup which may be called in Form::send_email
431 $::form->{cwd} = getcwd();
432 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
434 $::form->{$_} = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
435 $::form->{media} = 'email';
437 if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) {
439 my @errors = generate_pdf($self->order, \$pdf, {media => $::form->{media},
440 format => $::form->{print_options}->{format},
441 formname => $::form->{print_options}->{formname},
442 language => $self->order->language,
443 printer_id => $::form->{print_options}->{printer_id},
444 groupitems => $::form->{print_options}->{groupitems}});
445 if (scalar @errors) {
446 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
449 my $sfile = SL::SessionFile::Random->new(mode => "w");
450 $sfile->fh->print($pdf);
453 $::form->{tmpfile} = $sfile->file_name;
454 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
457 $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a linked record to the mail
458 $::form->send_email(\%::myconfig, 'pdf');
461 my $intnotes = $self->order->intnotes;
462 $intnotes .= "\n\n" if $self->order->intnotes;
463 $intnotes .= t8('[email]') . "\n";
464 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
465 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
466 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
467 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
468 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
469 $intnotes .= t8('Message') . ": " . $::form->{message};
471 $self->order->update_attributes(intnotes => $intnotes);
473 $self->save_history('MAILED');
475 flash_later('info', t8('The email has been sent.'));
477 my @redirect_params = (
480 id => $self->order->id,
483 $self->redirect_to(@redirect_params);
486 # open the periodic invoices config dialog
488 # If there are values in the form (i.e. dialog was opened before),
489 # then use this values. Create new ones, else.
490 sub action_show_periodic_invoices_config_dialog {
493 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
494 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
495 $config ||= SL::DB::PeriodicInvoicesConfig->new(periodicity => 'm',
496 order_value_periodicity => 'p', # = same as periodicity
497 start_date_as_date => $::form->{transdate_as_date} || $::form->current_date,
498 extend_automatically_by => 12,
500 email_subject => GenericTranslations->get(
501 language_id => $::form->{language_id},
502 translation_type =>"preset_text_periodic_invoices_email_subject"),
503 email_body => GenericTranslations->get(
504 language_id => $::form->{language_id},
505 translation_type =>"preset_text_periodic_invoices_email_body"),
507 $config->periodicity('m') if none { $_ eq $config->periodicity } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
508 $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
510 $::form->get_lists(printers => "ALL_PRINTERS",
511 charts => { key => 'ALL_CHARTS',
512 transdate => 'current_date' });
514 $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
516 if ($::form->{customer_id}) {
517 $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
518 $::form->{email_recipient_invoice_address} = SL::DB::Manager::Customer->find_by(id => $::form->{customer_id})->invoice_mail;
521 $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
523 popup_js_close_function => 'kivi.Order.close_periodic_invoices_config_dialog()',
524 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
529 # assign the values of the periodic invoices config dialog
530 # as yaml in the hidden tag and set the status.
531 sub action_assign_periodic_invoices_config {
534 $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
536 my $config = { active => $::form->{active} ? 1 : 0,
537 terminated => $::form->{terminated} ? 1 : 0,
538 direct_debit => $::form->{direct_debit} ? 1 : 0,
539 periodicity => (any { $_ eq $::form->{periodicity} } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES) ? $::form->{periodicity} : 'm',
540 order_value_periodicity => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
541 start_date_as_date => $::form->{start_date_as_date},
542 end_date_as_date => $::form->{end_date_as_date},
543 first_billing_date_as_date => $::form->{first_billing_date_as_date},
544 print => $::form->{print} ? 1 : 0,
545 printer_id => $::form->{print} ? $::form->{printer_id} * 1 : undef,
546 copies => $::form->{copies} * 1 ? $::form->{copies} : 1,
547 extend_automatically_by => $::form->{extend_automatically_by} * 1 || undef,
548 ar_chart_id => $::form->{ar_chart_id} * 1,
549 send_email => $::form->{send_email} ? 1 : 0,
550 email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
551 email_recipient_address => $::form->{email_recipient_address},
552 email_sender => $::form->{email_sender},
553 email_subject => $::form->{email_subject},
554 email_body => $::form->{email_body},
557 my $periodic_invoices_config = SL::YAML::Dump($config);
559 my $status = $self->get_periodic_invoices_status($config);
562 ->remove('#order_periodic_invoices_config')
563 ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
564 ->run('kivi.Order.close_periodic_invoices_config_dialog')
565 ->html('#periodic_invoices_status', $status)
566 ->flash('info', t8('The periodic invoices config has been assigned.'))
570 sub action_get_has_active_periodic_invoices {
573 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
574 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
576 my $has_active_periodic_invoices =
577 $self->type eq sales_order_type()
580 && (!$config->end_date || ($config->end_date > DateTime->today_local))
581 && $config->get_previous_billed_period_start_date;
583 $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
586 # save the order and redirect to the frontend subroutine for a new
588 sub action_save_and_delivery_order {
591 $self->save_and_redirect_to(
592 controller => 'oe.pl',
593 action => 'oe_delivery_order_from_order',
597 # save the order and redirect to the frontend subroutine for a new
599 sub action_save_and_invoice {
602 $self->save_and_redirect_to(
603 controller => 'oe.pl',
604 action => 'oe_invoice_from_order',
608 # workflow from sales order to sales quotation
609 sub action_sales_quotation {
610 $_[0]->workflow_sales_or_request_for_quotation();
613 # workflow from sales order to sales quotation
614 sub action_request_for_quotation {
615 $_[0]->workflow_sales_or_request_for_quotation();
618 # workflow from sales quotation to sales order
619 sub action_sales_order {
620 $_[0]->workflow_sales_or_purchase_order();
623 # workflow from rfq to purchase order
624 sub action_purchase_order {
625 $_[0]->workflow_sales_or_purchase_order();
628 # workflow from purchase order to ap transaction
629 sub action_save_and_ap_transaction {
632 $self->save_and_redirect_to(
633 controller => 'ap.pl',
634 action => 'add_from_purchase_order',
638 # set form elements in respect to a changed customer or vendor
640 # This action is called on an change of the customer/vendor picker.
641 sub action_customer_vendor_changed {
644 setup_order_from_cv($self->order);
647 my $cv_method = $self->cv;
649 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
650 $self->js->show('#cp_row');
652 $self->js->hide('#cp_row');
655 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
656 $self->js->show('#shipto_selection');
658 $self->js->hide('#shipto_selection');
661 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
664 ->replaceWith('#order_cp_id', $self->build_contact_select)
665 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
666 ->replaceWith('#shipto_inputs ', $self->build_shipto_inputs)
667 ->replaceWith('#business_info_row', $self->build_business_info_row)
668 ->val( '#order_taxzone_id', $self->order->taxzone_id)
669 ->val( '#order_taxincluded', $self->order->taxincluded)
670 ->val( '#order_currency_id', $self->order->currency_id)
671 ->val( '#order_payment_id', $self->order->payment_id)
672 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
673 ->val( '#order_intnotes', $self->order->intnotes)
674 ->val( '#order_language_id', $self->order->$cv_method->language_id)
675 ->focus( '#order_' . $self->cv . '_id')
676 ->run('kivi.Order.update_exchangerate');
678 $self->js_redisplay_amounts_and_taxes;
679 $self->js_redisplay_cvpartnumbers;
683 # open the dialog for customer/vendor details
684 sub action_show_customer_vendor_details_dialog {
687 my $is_customer = 'customer' eq $::form->{vc};
690 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
692 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
695 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
696 $details{discount_as_percent} = $cv->discount_as_percent;
697 $details{creditlimt} = $cv->creditlimit_as_number;
698 $details{business} = $cv->business->description if $cv->business;
699 $details{language} = $cv->language_obj->description if $cv->language_obj;
700 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
701 $details{payment_terms} = $cv->payment->description if $cv->payment;
702 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
704 foreach my $entry (@{ $cv->shipto }) {
705 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
707 foreach my $entry (@{ $cv->contacts }) {
708 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
711 $_[0]->render('common/show_vc_details', { layout => 0 },
712 is_customer => $is_customer,
717 # called if a unit in an existing item row is changed
718 sub action_unit_changed {
721 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
722 my $item = $self->order->items_sorted->[$idx];
724 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
725 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
730 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
731 $self->js_redisplay_line_values;
732 $self->js_redisplay_amounts_and_taxes;
736 # add an item row for a new item entered in the input row
737 sub action_add_item {
740 my $form_attr = $::form->{add_item};
742 return unless $form_attr->{parts_id};
744 my $item = new_item($self->order, $form_attr);
746 $self->order->add_items($item);
750 $self->get_item_cvpartnumber($item);
752 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
753 my $row_as_html = $self->p->render('order/tabs/_row',
759 if ($::form->{insert_before_item_id}) {
761 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
764 ->append('#row_table_id', $row_as_html);
767 if ( $item->part->is_assortment ) {
768 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
769 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
770 my $attr = { parts_id => $assortment_item->parts_id,
771 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
772 unit => $assortment_item->unit,
773 description => $assortment_item->part->description,
775 my $item = new_item($self->order, $attr);
777 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
778 $item->discount(1) unless $assortment_item->charge;
780 $self->order->add_items( $item );
782 $self->get_item_cvpartnumber($item);
783 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
784 my $row_as_html = $self->p->render('order/tabs/_row',
789 if ($::form->{insert_before_item_id}) {
791 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
794 ->append('#row_table_id', $row_as_html);
800 ->val('.add_item_input', '')
801 ->run('kivi.Order.init_row_handlers')
802 ->run('kivi.Order.renumber_positions')
803 ->focus('#add_item_parts_id_name');
805 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
807 $self->js_redisplay_amounts_and_taxes;
811 # add item rows for multiple items at once
812 sub action_add_multi_items {
815 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
816 return $self->js->render() unless scalar @form_attr;
819 foreach my $attr (@form_attr) {
820 my $item = new_item($self->order, $attr);
822 if ( $item->part->is_assortment ) {
823 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
824 my $attr = { parts_id => $assortment_item->parts_id,
825 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
826 unit => $assortment_item->unit,
827 description => $assortment_item->part->description,
829 my $item = new_item($self->order, $attr);
831 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
832 $item->discount(1) unless $assortment_item->charge;
837 $self->order->add_items(@items);
841 foreach my $item (@items) {
842 $self->get_item_cvpartnumber($item);
843 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
844 my $row_as_html = $self->p->render('order/tabs/_row',
850 if ($::form->{insert_before_item_id}) {
852 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
855 ->append('#row_table_id', $row_as_html);
860 ->run('kivi.Part.close_picker_dialogs')
861 ->run('kivi.Order.init_row_handlers')
862 ->run('kivi.Order.renumber_positions')
863 ->focus('#add_item_parts_id_name');
865 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
867 $self->js_redisplay_amounts_and_taxes;
871 # recalculate all linetotals, amounts and taxes and redisplay them
872 sub action_recalc_amounts_and_taxes {
877 $self->js_redisplay_line_values;
878 $self->js_redisplay_amounts_and_taxes;
882 sub action_update_exchangerate {
886 is_standard => $self->order->currency_id == $::instance_conf->get_currency_id,
887 currency_name => $self->order->currency->name,
888 exchangerate => $self->order->daily_exchangerate_as_null_number,
891 $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
894 # redisplay item rows if they are sorted by an attribute
895 sub action_reorder_items {
899 partnumber => sub { $_[0]->part->partnumber },
900 description => sub { $_[0]->description },
901 qty => sub { $_[0]->qty },
902 sellprice => sub { $_[0]->sellprice },
903 discount => sub { $_[0]->discount },
904 cvpartnumber => sub { $_[0]->{cvpartnumber} },
907 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
909 my $method = $sort_keys{$::form->{order_by}};
910 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
911 if ($::form->{sort_dir}) {
912 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
913 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
915 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
918 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
919 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
921 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
925 ->run('kivi.Order.redisplay_items', \@to_sort)
929 # show the popup to choose a price/discount source
930 sub action_price_popup {
933 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
934 my $item = $self->order->items_sorted->[$idx];
936 $self->render_price_dialog($item);
939 # load the second row for one or more items
941 # This action gets the html code for all items second rows by rendering a template for
942 # the second row and sets the html code via client js.
943 sub action_load_second_rows {
946 $self->recalc() if $self->order->is_sales; # for margin calculation
948 foreach my $item_id (@{ $::form->{item_ids} }) {
949 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
950 my $item = $self->order->items_sorted->[$idx];
952 $self->js_load_second_row($item, $item_id, 0);
955 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
960 # update description, notes and sellprice from master data
961 sub action_update_row_from_master_data {
964 foreach my $item_id (@{ $::form->{item_ids} }) {
965 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
966 my $item = $self->order->items_sorted->[$idx];
967 my $texts = get_part_texts($item->part, $self->order->language_id);
969 $item->description($texts->{description});
970 $item->longdescription($texts->{longdescription});
972 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
975 if ($item->part->is_assortment) {
976 # add assortment items with price 0, as the components carry the price
977 $price_src = $price_source->price_from_source("");
978 $price_src->price(0);
980 $price_src = $price_source->best_price
981 ? $price_source->best_price
982 : $price_source->price_from_source("");
983 $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate;
984 $price_src->price(0) if !$price_source->best_price;
988 $item->sellprice($price_src->price);
989 $item->active_price_source($price_src);
992 ->run('kivi.Order.update_sellprice', $item_id, $item->sellprice_as_number)
993 ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
994 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
995 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
997 if ($self->search_cvpartnumber) {
998 $self->get_item_cvpartnumber($item);
999 $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
1004 $self->js_redisplay_line_values;
1005 $self->js_redisplay_amounts_and_taxes;
1007 $self->js->render();
1010 sub js_load_second_row {
1011 my ($self, $item, $item_id, $do_parse) = @_;
1014 # Parse values from form (they are formated while rendering (template)).
1015 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1016 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1017 foreach my $var (@{ $item->cvars_by_config }) {
1018 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1020 $item->parse_custom_variable_values;
1023 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1026 ->html('#second_row_' . $item_id, $row_as_html)
1027 ->data('#second_row_' . $item_id, 'loaded', 1);
1030 sub js_redisplay_line_values {
1033 my $is_sales = $self->order->is_sales;
1035 # sales orders with margins
1040 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1041 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1042 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1043 ]} @{ $self->order->items_sorted };
1047 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1048 ]} @{ $self->order->items_sorted };
1052 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1055 sub js_redisplay_amounts_and_taxes {
1058 if (scalar @{ $self->{taxes} }) {
1059 $self->js->show('#taxincluded_row_id');
1061 $self->js->hide('#taxincluded_row_id');
1064 if ($self->order->taxincluded) {
1065 $self->js->hide('#subtotal_row_id');
1067 $self->js->show('#subtotal_row_id');
1070 if ($self->order->is_sales) {
1071 my $is_neg = $self->order->marge_total < 0;
1073 ->html('#marge_total_id', $::form->format_amount(\%::myconfig, $self->order->marge_total, 2))
1074 ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
1075 ->action_if( $is_neg, 'addClass', '#marge_total_id', 'plus0')
1076 ->action_if( $is_neg, 'addClass', '#marge_percent_id', 'plus0')
1077 ->action_if( $is_neg, 'addClass', '#marge_percent_sign_id', 'plus0')
1078 ->action_if(!$is_neg, 'removeClass', '#marge_total_id', 'plus0')
1079 ->action_if(!$is_neg, 'removeClass', '#marge_percent_id', 'plus0')
1080 ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
1084 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
1085 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
1086 ->remove('.tax_row')
1087 ->insertBefore($self->build_tax_rows, '#amount_row_id');
1090 sub js_redisplay_cvpartnumbers {
1093 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1095 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1098 ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
1101 sub js_reset_order_and_item_ids_after_save {
1105 ->val('#id', $self->order->id)
1106 ->val('#converted_from_oe_id', '')
1107 ->val('#order_' . $self->nr_key(), $self->order->number);
1110 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1111 next if !$self->order->items_sorted->[$idx]->id;
1112 next if $form_item_id !~ m{^new};
1114 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1115 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1116 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1120 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1127 sub init_valid_types {
1128 [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
1134 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1135 die "Not a valid type for order";
1138 $self->type($::form->{type});
1144 my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer'
1145 : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
1146 : die "Not a valid type for order";
1151 sub init_search_cvpartnumber {
1154 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1155 my $search_cvpartnumber;
1156 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1157 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1159 return $search_cvpartnumber;
1162 sub init_show_update_button {
1165 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1176 sub init_all_price_factors {
1177 SL::DB::Manager::PriceFactor->get_all;
1180 sub init_part_picker_classification_ids {
1182 my $attribute = 'used_for_' . ($self->type =~ m{sales} ? 'sale' : 'purchase');
1184 return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => [ $attribute => 1 ]) } ];
1190 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
1192 my $right = $right_for->{ $self->type };
1193 $right ||= 'DOES_NOT_EXIST';
1195 $::auth->assert($right);
1198 # build the selection box for contacts
1200 # Needed, if customer/vendor changed.
1201 sub build_contact_select {
1204 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1205 value_key => 'cp_id',
1206 title_key => 'full_name_dep',
1207 default => $self->order->cp_id,
1209 style => 'width: 300px',
1213 # build the selection box for shiptos
1215 # Needed, if customer/vendor changed.
1216 sub build_shipto_select {
1219 select_tag('order.shipto_id',
1220 [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
1221 value_key => 'shipto_id',
1222 title_key => 'displayable_id',
1223 default => $self->order->shipto_id,
1225 style => 'width: 300px',
1229 # build the inputs for the cusom shipto dialog
1231 # Needed, if customer/vendor changed.
1232 sub build_shipto_inputs {
1235 my $content = $self->p->render('common/_ship_to_dialog',
1236 vc_obj => $self->order->customervendor,
1237 cs_obj => $self->order->custom_shipto,
1238 cvars => $self->order->custom_shipto->cvars_by_config,
1239 id_selector => '#order_shipto_id');
1241 div_tag($content, id => 'shipto_inputs');
1244 # render the info line for business
1246 # Needed, if customer/vendor changed.
1247 sub build_business_info_row
1249 $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
1252 # build the rows for displaying taxes
1254 # Called if amounts where recalculated and redisplayed.
1255 sub build_tax_rows {
1259 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
1260 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
1262 return $rows_as_html;
1266 sub render_price_dialog {
1267 my ($self, $record_item) = @_;
1269 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1273 'kivi.io.price_chooser_dialog',
1274 t8('Available Prices'),
1275 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1280 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1281 # $self->js->show('#dialog_flash_error');
1290 return if !$::form->{id};
1292 $self->order(SL::DB::Order->new(id => $::form->{id})->load);
1294 # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
1295 # You need a custom shipto object to call cvars_by_config to get the cvars.
1296 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
1298 return $self->order;
1301 # load or create a new order object
1303 # And assign changes from the form to this object.
1304 # If the order is loaded from db, check if items are deleted in the form,
1305 # remove them form the object and collect them for removing from db on saving.
1306 # Then create/update items from form (via make_item) and add them.
1310 # add_items adds items to an order with no items for saving, but they cannot
1311 # be retrieved via items until the order is saved. Adding empty items to new
1312 # order here solves this problem.
1314 $order = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1315 $order ||= SL::DB::Order->new(orderitems => [],
1316 quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())),
1317 currency_id => $::instance_conf->get_currency_id(),);
1319 my $cv_id_method = $self->cv . '_id';
1320 if (!$::form->{id} && $::form->{$cv_id_method}) {
1321 $order->$cv_id_method($::form->{$cv_id_method});
1322 setup_order_from_cv($order);
1325 my $form_orderitems = delete $::form->{order}->{orderitems};
1326 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1328 $order->assign_attributes(%{$::form->{order}});
1330 $self->setup_custom_shipto_from_form($order, $::form);
1332 if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) {
1333 my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
1334 $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
1337 # remove deleted items
1338 $self->item_ids_to_delete([]);
1339 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1340 my $item = $order->orderitems->[$idx];
1341 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1342 splice @{$order->orderitems}, $idx, 1;
1343 push @{$self->item_ids_to_delete}, $item->id;
1349 foreach my $form_attr (@{$form_orderitems}) {
1350 my $item = make_item($order, $form_attr);
1351 $item->position($pos);
1355 $order->add_items(grep {!$_->id} @items);
1360 # create or update items from form
1362 # Make item objects from form values. For items already existing read from db.
1363 # Create a new item else. And assign attributes.
1365 my ($record, $attr) = @_;
1368 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1370 my $is_new = !$item;
1372 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1373 # they cannot be retrieved via custom_variables until the order/orderitem is
1374 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1375 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1377 $item->assign_attributes(%$attr);
1380 my $texts = get_part_texts($item->part, $record->language_id);
1381 $item->longdescription($texts->{longdescription}) if !defined $attr->{longdescription};
1382 $item->project_id($record->globalproject_id) if !defined $attr->{project_id};
1383 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number};
1391 # This is used to add one item
1393 my ($record, $attr) = @_;
1395 my $item = SL::DB::OrderItem->new;
1397 # Remove attributes where the user left or set the inputs empty.
1398 # So these attributes will be undefined and we can distinguish them
1399 # from zero later on.
1400 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1401 delete $attr->{$_} if $attr->{$_} eq '';
1404 $item->assign_attributes(%$attr);
1406 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1407 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1409 $item->unit($part->unit) if !$item->unit;
1412 if ( $part->is_assortment ) {
1413 # add assortment items with price 0, as the components carry the price
1414 $price_src = $price_source->price_from_source("");
1415 $price_src->price(0);
1416 } elsif (defined $item->sellprice) {
1417 $price_src = $price_source->price_from_source("");
1418 $price_src->price($item->sellprice);
1420 $price_src = $price_source->best_price
1421 ? $price_source->best_price
1422 : $price_source->price_from_source("");
1423 $price_src->price($::form->round_amount($price_src->price / $record->exchangerate, 5)) if $record->exchangerate;
1424 $price_src->price(0) if !$price_source->best_price;
1428 if (defined $item->discount) {
1429 $discount_src = $price_source->discount_from_source("");
1430 $discount_src->discount($item->discount);
1432 $discount_src = $price_source->best_discount
1433 ? $price_source->best_discount
1434 : $price_source->discount_from_source("");
1435 $discount_src->discount(0) if !$price_source->best_discount;
1439 $new_attr{part} = $part;
1440 $new_attr{description} = $part->description if ! $item->description;
1441 $new_attr{qty} = 1.0 if ! $item->qty;
1442 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1443 $new_attr{sellprice} = $price_src->price;
1444 $new_attr{discount} = $discount_src->discount;
1445 $new_attr{active_price_source} = $price_src;
1446 $new_attr{active_discount_source} = $discount_src;
1447 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1448 $new_attr{project_id} = $record->globalproject_id;
1449 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1451 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1452 # they cannot be retrieved via custom_variables until the order/orderitem is
1453 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1454 $new_attr{custom_variables} = [];
1456 my $texts = get_part_texts($part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription});
1458 $item->assign_attributes(%new_attr, %{ $texts });
1463 sub setup_order_from_cv {
1466 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id));
1468 $order->intnotes($order->customervendor->notes);
1470 if ($order->is_sales) {
1471 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1472 $order->taxincluded(defined($order->customer->taxincluded_checked)
1473 ? $order->customer->taxincluded_checked
1474 : $::myconfig{taxincluded_checked});
1479 # setup custom shipto from form
1481 # The dialog returns form variables starting with 'shipto' and cvars starting
1482 # with 'shiptocvar_'.
1483 # Mark it to be deleted if a shipto from master data is selected
1484 # (i.e. order has a shipto).
1485 # Else, update or create a new custom shipto. If the fields are empty, it
1486 # will not be saved on save.
1487 sub setup_custom_shipto_from_form {
1488 my ($self, $order, $form) = @_;
1490 if ($order->shipto) {
1491 $self->is_custom_shipto_to_delete(1);
1493 my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1495 my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
1496 my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
1498 $custom_shipto->assign_attributes(%$shipto_attrs);
1499 $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
1503 # recalculate prices and taxes
1505 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1509 my %pat = $self->order->calculate_prices_and_taxes();
1511 $self->{taxes} = [];
1512 foreach my $tax_id (keys %{ $pat{taxes_by_tax_id} }) {
1513 my $netamount = sum0 map { $pat{amounts}->{$_}->{amount} } grep { $pat{amounts}->{$_}->{tax_id} == $tax_id } keys %{ $pat{amounts} };
1515 push(@{ $self->{taxes} }, { amount => $pat{taxes_by_tax_id}->{$tax_id},
1516 netamount => $netamount,
1517 tax => SL::DB::Tax->new(id => $tax_id)->load });
1519 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items_sorted}, @{$pat{items}};
1522 # get data for saving, printing, ..., that is not changed in the form
1524 # Only cvars for now.
1525 sub get_unalterable_data {
1528 foreach my $item (@{ $self->order->items }) {
1529 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1530 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1531 foreach my $var (@{ $item->cvars_by_config }) {
1532 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1534 $item->parse_custom_variable_values;
1540 # And remove related files in the spool directory
1545 my $db = $self->order->db;
1547 $db->with_transaction(
1549 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1550 $self->order->delete;
1551 my $spool = $::lx_office_conf{paths}->{spool};
1552 unlink map { "$spool/$_" } @spoolfiles if $spool;
1554 $self->save_history('DELETED');
1557 }) || push(@{$errors}, $db->error);
1564 # And delete items that are deleted in the form.
1569 my $db = $self->order->db;
1571 $db->with_transaction(sub {
1572 # delete custom shipto if it is to be deleted or if it is empty
1573 if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
1574 $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
1575 $self->order->custom_shipto(undef);
1578 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1579 $self->order->save(cascade => 1);
1582 if ($::form->{converted_from_oe_id}) {
1583 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1584 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1585 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1586 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
1587 $src->link_to_record($self->order);
1589 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1591 foreach (@{ $self->order->items_sorted }) {
1592 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1594 SL::DB::RecordLink->new(from_table => 'orderitems',
1595 from_id => $from_id,
1596 to_table => 'orderitems',
1604 $self->save_history('SAVED');
1607 }) || push(@{$errors}, $db->error);
1612 sub workflow_sales_or_request_for_quotation {
1616 my $errors = $self->save();
1618 if (scalar @{ $errors }) {
1619 $self->js->flash('error', $_) for @{ $errors };
1620 return $self->js->render();
1623 my $destination_type = $::form->{type} eq sales_order_type() ? sales_quotation_type() : request_quotation_type();
1625 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1626 $self->{converted_from_oe_id} = delete $::form->{id};
1628 # set item ids to new fake id, to identify them as new items
1629 foreach my $item (@{$self->order->items_sorted}) {
1630 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1634 $::form->{type} = $destination_type;
1635 $self->type($self->init_type);
1636 $self->cv ($self->init_cv);
1640 $self->get_unalterable_data();
1641 $self->pre_render();
1643 # trigger rendering values for second row as hidden, because they
1644 # are loaded only on demand. So we need to keep the values from the
1646 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1650 title => $self->get_title_for('edit'),
1651 %{$self->{template_args}}
1655 sub workflow_sales_or_purchase_order {
1659 my $errors = $self->save();
1661 if (scalar @{ $errors }) {
1662 $self->js->flash('error', $_) foreach @{ $errors };
1663 return $self->js->render();
1666 my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type()
1667 : $::form->{type} eq request_quotation_type() ? purchase_order_type()
1668 : $::form->{type} eq purchase_order_type() ? sales_order_type()
1669 : $::form->{type} eq sales_order_type() ? purchase_order_type()
1672 # check for direct delivery
1673 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
1675 if ( $::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()
1676 && $::form->{use_shipto} && $self->order->shipto) {
1677 $custom_shipto = $self->order->shipto->clone('SL::DB::Order');
1680 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1681 $self->{converted_from_oe_id} = delete $::form->{id};
1683 # set item ids to new fake id, to identify them as new items
1684 foreach my $item (@{$self->order->items_sorted}) {
1685 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1688 if ($::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()) {
1689 if ($::form->{use_shipto}) {
1690 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
1692 # remove any custom shipto if not wanted
1693 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1698 $::form->{type} = $destination_type;
1699 $self->type($self->init_type);
1700 $self->cv ($self->init_cv);
1704 $self->get_unalterable_data();
1705 $self->pre_render();
1707 # trigger rendering values for second row as hidden, because they
1708 # are loaded only on demand. So we need to keep the values from the
1710 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1714 title => $self->get_title_for('edit'),
1715 %{$self->{template_args}}
1723 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1724 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
1725 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1726 $self->{all_languages} = SL::DB::Manager::Language->get_all_sorted();
1727 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1730 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1733 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1735 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1736 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1737 $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
1738 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1739 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1741 my $print_form = Form->new('');
1742 $print_form->{type} = $self->type;
1743 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1744 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1745 form => $print_form,
1746 options => {dialog_name_prefix => 'print_options.',
1750 no_opendocument => 0,
1754 foreach my $item (@{$self->order->orderitems}) {
1755 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1756 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1757 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1760 if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
1761 # calculate shipped qtys here to prevent calling calculate for every item via the items method
1762 SL::Helper::ShippedQty->new->calculate($self->order)->write_to_objects;
1765 if ($self->order->number && $::instance_conf->get_webdav) {
1766 my $webdav = SL::Webdav->new(
1767 type => $self->type,
1768 number => $self->order->number,
1770 my @all_objects = $webdav->get_all_objects;
1771 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1773 link => File::Spec->catfile($_->full_filedescriptor),
1777 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1779 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
1780 edit_periodic_invoices_config calculate_qty kivi.Validator follow_up show_history);
1781 $self->setup_edit_action_bar;
1784 sub setup_edit_action_bar {
1785 my ($self, %params) = @_;
1787 my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
1788 || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
1789 || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
1791 for my $bar ($::request->layout->get('actionbar')) {
1796 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1797 $::instance_conf->get_order_warn_no_deliverydate,
1799 checks => [ 'kivi.Order.check_save_active_periodic_invoices', ['kivi.validate_form','#order_form'] ],
1803 call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1804 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1805 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1807 ], # end of combobox "Save"
1814 t8('Save and Quotation'),
1815 submit => [ '#order_form', { action => "Order/sales_quotation" } ],
1816 only_if => (any { $self->type eq $_ } (sales_order_type())),
1820 submit => [ '#order_form', { action => "Order/request_for_quotation" } ],
1821 only_if => (any { $self->type eq $_ } (purchase_order_type())),
1824 t8('Save and Sales Order'),
1825 submit => [ '#order_form', { action => "Order/sales_order" } ],
1826 only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
1829 t8('Save and Purchase Order'),
1830 call => [ 'kivi.Order.purchase_order_check_for_direct_delivery' ],
1831 only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
1834 t8('Save and Delivery Order'),
1835 call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1836 $::instance_conf->get_order_warn_no_deliverydate,
1838 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1839 only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type()))
1842 t8('Save and Invoice'),
1843 call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1844 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1847 t8('Save and AP Transaction'),
1848 call => [ 'kivi.Order.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
1849 only_if => (any { $self->type eq $_ } (purchase_order_type()))
1852 ], # end of combobox "Workflow"
1859 t8('Save and print'),
1860 call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts ],
1863 t8('Save and E-mail'),
1864 call => [ 'kivi.Order.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts ],
1865 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1868 t8('Download attachments of all parts'),
1869 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1870 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1871 only_if => $::instance_conf->get_doc_storage,
1873 ], # end of combobox "Export"
1877 call => [ 'kivi.Order.delete_order' ],
1878 confirm => $::locale->text('Do you really want to delete this object?'),
1879 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1880 only_if => $deletion_allowed,
1889 call => [ 'kivi.Order.follow_up_window' ],
1890 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1891 only_if => $::auth->assert('productivity', 1),
1895 call => [ 'set_history_window', $self->order->id, 'id' ],
1896 disabled => !$self->order->id ? t8('This record has not been saved yet.') : undef,
1898 ], # end of combobox "more"
1904 my ($order, $pdf_ref, $params) = @_;
1908 my $print_form = Form->new('');
1909 $print_form->{type} = $order->type;
1910 $print_form->{formname} = $params->{formname} || $order->type;
1911 $print_form->{format} = $params->{format} || 'pdf';
1912 $print_form->{media} = $params->{media} || 'file';
1913 $print_form->{groupitems} = $params->{groupitems};
1914 $print_form->{printer_id} = $params->{printer_id};
1915 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1917 $order->language($params->{language});
1918 $order->flatten_to_form($print_form, format_amounts => 1);
1922 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
1923 $template_ext = 'odt';
1924 $template_type = 'OpenDocument';
1927 # search for the template
1928 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1929 name => $print_form->{formname},
1930 extension => $template_ext,
1931 email => $print_form->{media} eq 'email',
1932 language => $params->{language},
1933 printer_id => $print_form->{printer_id},
1936 if (!defined $template_file) {
1937 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);
1940 return @errors if scalar @errors;
1942 $print_form->throw_on_error(sub {
1944 $print_form->prepare_for_printing;
1946 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1947 format => $print_form->{format},
1948 template_type => $template_type,
1949 template => $template_file,
1950 variables => $print_form,
1951 variable_content_types => {
1952 longdescription => 'html',
1953 partnotes => 'html',
1958 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
1964 sub get_files_for_email_dialog {
1967 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
1969 return %files if !$::instance_conf->get_doc_storage;
1971 if ($self->order->id) {
1972 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
1973 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
1974 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
1978 uniq_by { $_->{id} }
1980 +{ id => $_->part->id,
1981 partnumber => $_->part->partnumber }
1982 } @{$self->order->items_sorted};
1984 foreach my $part (@parts) {
1985 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
1986 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
1989 foreach my $key (keys %files) {
1990 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
1996 sub make_periodic_invoices_config_from_yaml {
1997 my ($yaml_config) = @_;
1999 return if !$yaml_config;
2000 my $attr = SL::YAML::Load($yaml_config);
2001 return if 'HASH' ne ref $attr;
2002 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
2006 sub get_periodic_invoices_status {
2007 my ($self, $config) = @_;
2009 return if $self->type ne sales_order_type();
2010 return t8('not configured') if !$config;
2012 my $active = ('HASH' eq ref $config) ? $config->{active}
2013 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
2014 : die "Cannot get status of periodic invoices config";
2016 return $active ? t8('active') : t8('inactive');
2020 my ($self, $action) = @_;
2022 return '' if none { lc($action)} qw(add edit);
2025 # $::locale->text("Add Sales Order");
2026 # $::locale->text("Add Purchase Order");
2027 # $::locale->text("Add Quotation");
2028 # $::locale->text("Add Request for Quotation");
2029 # $::locale->text("Edit Sales Order");
2030 # $::locale->text("Edit Purchase Order");
2031 # $::locale->text("Edit Quotation");
2032 # $::locale->text("Edit Request for Quotation");
2034 $action = ucfirst(lc($action));
2035 return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order")
2036 : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order")
2037 : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation")
2038 : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
2042 sub get_item_cvpartnumber {
2043 my ($self, $item) = @_;
2045 return if !$self->search_cvpartnumber;
2046 return if !$self->order->customervendor;
2048 if ($self->cv eq 'vendor') {
2049 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
2050 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
2051 } elsif ($self->cv eq 'customer') {
2052 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
2053 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
2057 sub get_part_texts {
2058 my ($part_or_id, $language_or_id, %defaults) = @_;
2060 my $part = ref($part_or_id) ? $part_or_id : SL::DB::Part->load_cached($part_or_id);
2061 my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id;
2063 description => $defaults{description} // $part->description,
2064 longdescription => $defaults{longdescription} // $part->notes,
2067 return $texts unless $language_id;
2069 my $translation = SL::DB::Manager::Translation->get_first(
2071 parts_id => $part->id,
2072 language_id => $language_id,
2075 $texts->{description} = $translation->translation if $translation && $translation->translation;
2076 $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription;
2081 sub sales_order_type {
2085 sub purchase_order_type {
2089 sub sales_quotation_type {
2093 sub request_quotation_type {
2094 'request_quotation';
2098 return $_[0]->type eq sales_order_type() ? 'ordnumber'
2099 : $_[0]->type eq purchase_order_type() ? 'ordnumber'
2100 : $_[0]->type eq sales_quotation_type() ? 'quonumber'
2101 : $_[0]->type eq request_quotation_type() ? 'quonumber'
2105 sub save_and_redirect_to {
2106 my ($self, %params) = @_;
2108 my $errors = $self->save();
2110 if (scalar @{ $errors }) {
2111 $self->js->flash('error', $_) foreach @{ $errors };
2112 return $self->js->render();
2115 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
2116 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
2117 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
2118 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
2120 flash_later('info', $text);
2122 $self->redirect_to(%params, id => $self->order->id);
2126 my ($self, $addition) = @_;
2128 my $number_type = $self->order->type =~ m{order} ? 'ordnumber' : 'quonumber';
2129 my $snumbers = $number_type . '_' . $self->order->$number_type;
2131 SL::DB::History->new(
2132 trans_id => $self->order->id,
2133 employee_id => SL::DB::Manager::Employee->current->id,
2134 what_done => $self->order->type,
2135 snumbers => $snumbers,
2136 addition => $addition,
2148 SL::Controller::Order - controller for orders
2152 This is a new form to enter orders, completely rewritten with the use
2153 of controller and java script techniques.
2155 The aim is to provide the user a better experience and a faster workflow. Also
2156 the code should be more readable, more reliable and better to maintain.
2164 One input row, so that input happens every time at the same place.
2168 Use of pickers where possible.
2172 Possibility to enter more than one item at once.
2176 Item list in a scrollable area, so that the workflow buttons stay at
2181 Reordering item rows with drag and drop is possible. Sorting item rows is
2182 possible (by partnumber, description, qty, sellprice and discount for now).
2186 No C<update> is necessary. All entries and calculations are managed
2187 with ajax-calls and the page only reloads on C<save>.
2191 User can see changes immediately, because of the use of java script
2202 =item * C<SL/Controller/Order.pm>
2206 =item * C<template/webpages/order/form.html>
2210 =item * C<template/webpages/order/tabs/basic_data.html>
2212 Main tab for basic_data.
2214 This is the only tab here for now. "linked records" and "webdav" tabs are
2215 reused from generic code.
2219 =item * C<template/webpages/order/tabs/_business_info_row.html>
2221 For displaying information on business type
2223 =item * C<template/webpages/order/tabs/_item_input.html>
2225 The input line for items
2227 =item * C<template/webpages/order/tabs/_row.html>
2229 One row for already entered items
2231 =item * C<template/webpages/order/tabs/_tax_row.html>
2233 Displaying tax information
2235 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
2237 Dialog for selecting price and discount sources
2241 =item * C<js/kivi.Order.js>
2243 java script functions
2253 =item * price sources: little symbols showing better price / better discount
2255 =item * select units in input row?
2257 =item * check for direct delivery (workflow sales order -> purchase order)
2259 =item * access rights
2261 =item * display weights
2265 =item * optional client/user behaviour
2267 (transactions has to be set - department has to be set -
2268 force project if enabled in client config - transport cost reminder)
2272 =head1 KNOWN BUGS AND CAVEATS
2278 Customer discount is not displayed as a valid discount in price source popup
2279 (this might be a bug in price sources)
2281 (I cannot reproduce this (Bernd))
2285 No indication that <shift>-up/down expands/collapses second row.
2289 Inline creation of parts is not currently supported
2293 Table header is not sticky in the scrolling area.
2297 Sorting does not include C<position>, neither does reordering.
2299 This behavior was implemented intentionally. But we can discuss, which behavior
2300 should be implemented.
2304 =head1 To discuss / Nice to have
2310 How to expand/collapse second row. Now it can be done clicking the icon or
2315 Possibility to select PriceSources in input row?
2319 This controller uses a (changed) copy of the template for the PriceSource
2320 dialog. Maybe there could be used one code source.
2324 Rounding-differences between this controller (PriceTaxCalculator) and the old
2325 form. This is not only a problem here, but also in all parts using the PTC.
2326 There exists a ticket and a patch. This patch should be testet.
2330 An indicator, if the actual inputs are saved (like in an
2331 editor or on text processing application).
2335 A warning when leaving the page without saveing unchanged inputs.
2342 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>