1 package SL::Controller::DeliveryOrder;
4 use parent qw(SL::Controller::Base);
6 use SL::Helper::Flash qw(flash_later);
7 use SL::Helper::Number qw(_format_number _parse_number);
8 use SL::Presenter::Tag qw(select_tag hidden_tag div_tag);
9 use SL::Presenter::DeliveryOrder qw(delivery_order_status_line);
10 use SL::Locale::String qw(t8);
11 use SL::SessionFile::Random;
16 use SL::Util qw(trim);
24 use SL::DB::PartClassification;
25 use SL::DB::PartsGroup;
28 use SL::DB::RecordLink;
30 use SL::DB::Translation;
31 use SL::DB::TransferType;
33 use SL::Helper::CreatePDF qw(:all);
34 use SL::Helper::PrintOptions;
35 use SL::Helper::ShippedQty;
36 use SL::Helper::UserPreferences::PositionsScrollbar;
37 use SL::Helper::UserPreferences::UpdatePositions;
39 use SL::Controller::Helper::GetModels;
40 use SL::Controller::DeliveryOrder::TypeData qw(:types);
42 use List::Util qw(first sum0);
43 use List::UtilsBy qw(sort_by uniq_by);
44 use List::MoreUtils qw(any none pairwise first_index);
45 use English qw(-no_match_vars);
50 use Rose::Object::MakeMethods::Generic
52 scalar => [ qw(item_ids_to_delete is_custom_shipto_to_delete) ],
53 'scalar --get_set_init' => [ qw(order valid_types type cv p all_price_factors search_cvpartnumber show_update_button part_picker_classification_ids type_data) ],
58 __PACKAGE__->run_before('check_auth',
59 except => [ qw(update_stock_information) ]);
61 __PACKAGE__->run_before('check_auth_for_edit',
62 except => [ qw(update_stock_information edit show_customer_vendor_details_dialog price_popup stock_in_out_dialog load_second_rows) ]);
64 __PACKAGE__->run_before('get_unalterable_data',
65 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
76 $self->order->transdate(DateTime->now_local());
77 $self->type_data->set_reqdate_by_type;
82 'delivery_order/form',
83 title => $self->get_title_for('add'),
84 %{$self->{template_args}}
88 sub action_add_from_order {
90 # this interfers with init_order
91 $self->{converted_from_oe_id} = delete $::form->{id};
93 $self->type_data->validate($::form->{type});
95 my $order = SL::DB::Order->new(id => $self->{converted_from_oe_id})->load;
97 $self->order(SL::DB::DeliveryOrder->new_from($order, type => $::form->{type}));
102 # edit an existing order
110 # this is to edit an order from an unsaved order object
112 # set item ids to new fake id, to identify them as new items
113 foreach my $item (@{$self->order->items_sorted}) {
114 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
116 # trigger rendering values for second row as hidden, because they
117 # are loaded only on demand. So we need to keep the values from
119 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
124 'delivery_order/form',
125 title => $self->get_title_for('edit'),
126 %{$self->{template_args}}
130 # edit a collective order (consisting of one or more existing orders)
131 sub action_edit_collective {
135 my @multi_ids = map {
136 $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1}
137 } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form;
139 # fall back to add if no ids are given
140 if (scalar @multi_ids == 0) {
145 # fall back to save as new if only one id is given
146 if (scalar @multi_ids == 1) {
147 $self->order(SL::DB::DeliveryOrder->new(id => $multi_ids[0])->load);
148 $self->action_save_as_new();
152 # make new order from given orders
153 my @multi_orders = map { SL::DB::DeliveryOrder->new(id => $_)->load } @multi_ids;
154 $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders;
155 $self->order(SL::DB::DeliveryOrder->new_from_multi(\@multi_orders, sort_sources_by => 'transdate'));
157 $self->action_edit();
164 my $errors = $self->delete();
166 if (scalar @{ $errors }) {
167 $self->js->flash('error', $_) foreach @{ $errors };
168 return $self->js->render();
171 flash_later('info', $self->type_data->text("delete"));
173 my @redirect_params = (
178 $self->redirect_to(@redirect_params);
185 my $errors = $self->save();
187 if (scalar @{ $errors }) {
188 $self->js->flash('error', $_) foreach @{ $errors };
189 return $self->js->render();
192 flash_later('info', $self->type_data->text("saved"));
194 my @redirect_params = (
197 id => $self->order->id,
200 $self->redirect_to(@redirect_params);
203 # save the order as new document an open it for edit
204 sub action_save_as_new {
207 my $order = $self->order;
210 $self->js->flash('error', t8('This object has not been saved yet.'));
211 return $self->js->render();
214 # load order from db to check if values changed
215 my $saved_order = SL::DB::DeliveryOrder->new(id => $order->id)->load;
218 # Lets assign a new number if the user hasn't changed the previous one.
219 # If it has been changed manually then use it as-is.
220 $new_attrs{number} = (trim($order->number) eq $saved_order->number)
222 : trim($order->number);
224 # Clear transdate unless changed
225 $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
226 ? DateTime->today_local
229 # Set new reqdate unless changed if it is enabled in client config
230 $new_attrs{reqdate} = $self->type_data->get_reqdate_by_type($order->reqdate, $saved_order->reqdate);
233 $new_attrs{employee} = SL::DB::Manager::Employee->current;
235 # Create new record from current one
236 $self->order(SL::DB::DeliveryOrder->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 my @warnings = store_pdf_to_webdav_and_filemanagement($self->order, $pdf, $pdf_filename);
320 if (scalar @warnings) {
321 $self->js->flash('warning', $_) for @warnings;
324 $self->save_history('PRINTED');
327 ->run('kivi.ActionBar.setEnabled', '#save_and_email_action')
330 sub action_preview_pdf {
333 my $errors = $self->save();
334 if (scalar @{ $errors }) {
335 $self->js->flash('error', $_) foreach @{ $errors };
336 return $self->js->render();
339 $self->js_reset_order_and_item_ids_after_save;
342 my $media = 'screen';
343 my $formname = $self->type;
346 # create a form for generate_attachment_filename
347 my $form = Form->new;
348 $form->{$self->nr_key()} = $self->order->number;
349 $form->{type} = $self->type;
350 $form->{format} = $format;
351 $form->{formname} = $formname;
352 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
353 my $pdf_filename = $form->generate_attachment_filename();
356 my @errors = generate_pdf($self->order, \$pdf, { format => $format,
357 formname => $formname,
358 language => $self->order->language,
360 if (scalar @errors) {
361 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
363 $self->save_history('PREVIEWED');
364 $self->js->flash('info', t8('The PDF has been previewed'));
368 type => SL::MIME->mime_type_from_ext($pdf_filename),
369 name => $pdf_filename,
374 # open the email dialog
375 sub action_save_and_show_email_dialog {
378 my $errors = $self->save();
380 if (scalar @{ $errors }) {
381 $self->js->flash('error', $_) foreach @{ $errors };
382 return $self->js->render();
385 my $cv_method = $self->cv;
387 if (!$self->order->$cv_method) {
388 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'))
393 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
394 $email_form->{to} ||= $self->order->$cv_method->email;
395 $email_form->{cc} = $self->order->$cv_method->cc;
396 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
397 # Todo: get addresses from shipto, if any
399 my $form = Form->new;
400 $form->{$self->nr_key()} = $self->order->number;
401 $form->{cusordnumber} = $self->order->cusordnumber;
402 $form->{formname} = $self->type;
403 $form->{type} = $self->type;
404 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
405 $form->{language_id} = $self->order->language->id if $self->order->language;
406 $form->{format} = 'pdf';
407 $form->{cp_id} = $self->order->contact->cp_id if $self->order->contact;
409 $email_form->{subject} = $form->generate_email_subject();
410 $email_form->{attachment_filename} = $form->generate_attachment_filename();
411 $email_form->{message} = $form->generate_email_body();
412 $email_form->{js_send_function} = 'kivi.DeliveryOrder.send_email()';
414 my %files = $self->get_files_for_email_dialog();
415 $self->{all_employees} = SL::DB::Manager::Employee->get_all(query => [ deleted => 0 ]);
416 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
417 email_form => $email_form,
418 show_bcc => $::auth->assert('email_bcc', 'may fail'),
420 is_customer => $self->type_data->is_customer,
421 ALL_EMPLOYEES => $self->{all_employees},
425 ->run('kivi.DeliveryOrder.show_email_dialog', $dialog_html)
432 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
433 sub action_send_email {
436 my $errors = $self->save();
438 if (scalar @{ $errors }) {
439 $self->js->run('kivi.DeliveryOrder.close_email_dialog');
440 $self->js->flash('error', $_) foreach @{ $errors };
441 return $self->js->render();
444 $self->js_reset_order_and_item_ids_after_save;
446 my $email_form = delete $::form->{email_form};
447 my %field_names = (to => 'email');
449 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
451 # for Form::cleanup which may be called in Form::send_email
452 $::form->{cwd} = getcwd();
453 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
455 $::form->{$_} = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
456 $::form->{media} = 'email';
458 if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) {
460 my @errors = generate_pdf($self->order, \$pdf, {media => $::form->{media},
461 format => $::form->{print_options}->{format},
462 formname => $::form->{print_options}->{formname},
463 language => $self->order->language,
464 printer_id => $::form->{print_options}->{printer_id},
465 groupitems => $::form->{print_options}->{groupitems}});
466 if (scalar @errors) {
467 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
470 my @warnings = store_pdf_to_webdav_and_filemanagement($self->order, $pdf, $::form->{attachment_filename});
471 if (scalar @warnings) {
472 flash_later('warning', $_) for @warnings;
475 my $sfile = SL::SessionFile::Random->new(mode => "w");
476 $sfile->fh->print($pdf);
479 $::form->{tmpfile} = $sfile->file_name;
480 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
483 $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a linked record to the mail
484 $::form->send_email(\%::myconfig, 'pdf');
487 my $intnotes = $self->order->intnotes;
488 $intnotes .= "\n\n" if $self->order->intnotes;
489 $intnotes .= t8('[email]') . "\n";
490 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
491 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
492 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
493 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
494 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
495 $intnotes .= t8('Message') . ": " . $::form->{message};
497 $self->order->update_attributes(intnotes => $intnotes);
499 $self->save_history('MAILED');
501 flash_later('info', t8('The email has been sent.'));
503 my @redirect_params = (
506 id => $self->order->id,
509 $self->redirect_to(@redirect_params);
512 # save the order and redirect to the frontend subroutine for a new
514 sub action_save_and_delivery_order {
517 $self->save_and_redirect_to(
518 controller => 'oe.pl',
519 action => 'oe_delivery_order_from_order',
523 # save the order and redirect to the frontend subroutine for a new
525 sub action_save_and_invoice {
528 $self->save_and_redirect_to(
529 controller => 'oe.pl',
530 action => 'oe_invoice_from_order',
534 # workflow from sales order to sales quotation
535 sub action_sales_quotation {
536 $_[0]->workflow_sales_or_request_for_quotation();
539 # workflow from sales order to sales quotation
540 sub action_request_for_quotation {
541 $_[0]->workflow_sales_or_request_for_quotation();
544 # workflow from sales quotation to sales order
545 sub action_sales_order {
546 $_[0]->workflow_sales_or_purchase_order();
549 # workflow from rfq to purchase order
550 sub action_purchase_order {
551 $_[0]->workflow_sales_or_purchase_order();
554 # workflow from purchase order to ap transaction
555 sub action_save_and_ap_transaction {
558 $self->save_and_redirect_to(
559 controller => 'ap.pl',
560 action => 'add_from_purchase_order',
564 # set form elements in respect to a changed customer or vendor
566 # This action is called on an change of the customer/vendor picker.
567 sub action_customer_vendor_changed {
570 setup_order_from_cv($self->order);
572 my $cv_method = $self->cv;
574 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
575 $self->js->show('#cp_row');
577 $self->js->hide('#cp_row');
580 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
581 $self->js->show('#shipto_selection');
583 $self->js->hide('#shipto_selection');
586 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
589 ->replaceWith('#order_cp_id', $self->build_contact_select)
590 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
591 ->replaceWith('#shipto_inputs ', $self->build_shipto_inputs)
592 ->replaceWith('#business_info_row', $self->build_business_info_row)
593 ->val( '#order_taxzone_id', $self->order->taxzone_id)
594 ->val( '#order_taxincluded', $self->order->taxincluded)
595 ->val( '#order_currency_id', $self->order->currency_id)
596 ->val( '#order_payment_id', $self->order->payment_id)
597 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
598 ->val( '#order_intnotes', $self->order->intnotes)
599 ->val( '#order_language_id', $self->order->$cv_method->language_id)
600 ->focus( '#order_' . $self->cv . '_id')
601 ->run('kivi.DeliveryOrder.update_exchangerate');
603 $self->js_redisplay_cvpartnumbers;
607 # open the dialog for customer/vendor details
608 sub action_show_customer_vendor_details_dialog {
611 my $is_customer = 'customer' eq $::form->{vc};
614 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
616 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
619 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
620 $details{discount_as_percent} = $cv->discount_as_percent;
621 $details{creditlimt} = $cv->creditlimit_as_number;
622 $details{business} = $cv->business->description if $cv->business;
623 $details{language} = $cv->language_obj->description if $cv->language_obj;
624 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
625 $details{payment_terms} = $cv->payment->description if $cv->payment;
626 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
628 foreach my $entry (@{ $cv->shipto }) {
629 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
631 foreach my $entry (@{ $cv->contacts }) {
632 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
635 $_[0]->render('common/show_vc_details', { layout => 0 },
636 is_customer => $is_customer,
641 # called if a unit in an existing item row is changed
642 sub action_unit_changed {
645 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
646 my $item = $self->order->items_sorted->[$idx];
648 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
649 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
652 ->run('kivi.DeliveryOrder.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
653 $self->js_redisplay_line_values;
657 # add an item row for a new item entered in the input row
658 sub action_add_item {
661 delete $::form->{add_item}->{create_part_type};
663 my $form_attr = $::form->{add_item};
665 return unless $form_attr->{parts_id};
667 my $item = new_item($self->order, $form_attr);
669 $self->order->add_items($item);
671 $self->get_item_cvpartnumber($item);
673 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
674 my $row_as_html = $self->p->render('delivery_order/tabs/_row',
678 in_out => $self->type_data->transfer,
681 if ($::form->{insert_before_item_id}) {
683 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
686 ->append('#row_table_id', $row_as_html);
689 if ( $item->part->is_assortment ) {
690 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
691 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
692 my $attr = { parts_id => $assortment_item->parts_id,
693 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
694 unit => $assortment_item->unit,
695 description => $assortment_item->part->description,
697 my $item = new_item($self->order, $attr);
699 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
700 $item->discount(1) unless $assortment_item->charge;
702 $self->order->add_items( $item );
703 $self->get_item_cvpartnumber($item);
704 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
705 my $row_as_html = $self->p->render('delivery_order/tabs/_row',
710 if ($::form->{insert_before_item_id}) {
712 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
715 ->append('#row_table_id', $row_as_html);
721 ->val('.add_item_input', '')
722 ->run('kivi.DeliveryOrder.init_row_handlers')
723 ->run('kivi.DeliveryOrder.renumber_positions')
724 ->focus('#add_item_parts_id_name');
726 $self->js->run('kivi.DeliveryOrder.row_table_scroll_down') if !$::form->{insert_before_item_id};
731 # add item rows for multiple items at once
732 sub action_add_multi_items {
735 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
736 return $self->js->render() unless scalar @form_attr;
739 foreach my $attr (@form_attr) {
740 my $item = new_item($self->order, $attr);
742 if ( $item->part->is_assortment ) {
743 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
744 my $attr = { parts_id => $assortment_item->parts_id,
745 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
746 unit => $assortment_item->unit,
747 description => $assortment_item->part->description,
749 my $item = new_item($self->order, $attr);
751 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
752 $item->discount(1) unless $assortment_item->charge;
757 $self->order->add_items(@items);
759 foreach my $item (@items) {
760 $self->get_item_cvpartnumber($item);
761 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
762 my $row_as_html = $self->p->render('delivery_order/tabs/_row',
766 in_out => $self->type_data->transfer,
769 if ($::form->{insert_before_item_id}) {
771 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
774 ->append('#row_table_id', $row_as_html);
779 ->run('kivi.Part.close_picker_dialogs')
780 ->run('kivi.DeliveryOrder.init_row_handlers')
781 ->run('kivi.DeliveryOrder.renumber_positions')
782 ->focus('#add_item_parts_id_name');
784 $self->js->run('kivi.DeliveryOrder.row_table_scroll_down') if !$::form->{insert_before_item_id};
789 sub action_update_exchangerate {
793 is_standard => $self->order->currency_id == $::instance_conf->get_currency_id,
794 currency_name => $self->order->currency->name,
797 $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
800 # redisplay item rows if they are sorted by an attribute
801 sub action_reorder_items {
805 partnumber => sub { $_[0]->part->partnumber },
806 description => sub { $_[0]->description },
807 qty => sub { $_[0]->qty },
808 sellprice => sub { $_[0]->sellprice },
809 discount => sub { $_[0]->discount },
810 cvpartnumber => sub { $_[0]->{cvpartnumber} },
813 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
815 my $method = $sort_keys{$::form->{order_by}};
816 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
817 if ($::form->{sort_dir}) {
818 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
819 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
821 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
824 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
825 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
827 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
831 ->run('kivi.DeliveryOrder.redisplay_items', \@to_sort)
835 # show the popup to choose a price/discount source
836 sub action_price_popup {
839 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
840 my $item = $self->order->items_sorted->[$idx];
842 $self->render_price_dialog($item);
845 # save the order in a session variable and redirect to the part controller
846 sub action_create_part {
849 my $previousform = $::auth->save_form_in_session(non_scalars => 1);
851 my $callback = $self->url_for(
852 action => 'return_from_create_part',
853 type => $self->type, # type is needed for check_auth on return
854 previousform => $previousform,
857 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.'));
859 my @redirect_params = (
860 controller => 'Part',
862 part_type => $::form->{add_item}->{create_part_type},
863 callback => $callback,
867 $self->redirect_to(@redirect_params);
870 sub action_return_from_create_part {
873 $self->{created_part} = SL::DB::Part->new(id => delete $::form->{new_parts_id})->load if $::form->{new_parts_id};
875 $::auth->restore_form_from_session(delete $::form->{previousform});
877 # set item ids to new fake id, to identify them as new items
878 foreach my $item (@{$self->order->items_sorted}) {
879 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
882 $self->get_unalterable_data();
885 # trigger rendering values for second row/longdescription as hidden,
886 # because they are loaded only on demand. So we need to keep the values
888 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
889 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
892 'delivery_order/form',
893 title => $self->get_title_for('edit'),
894 %{$self->{template_args}}
899 sub action_stock_in_out_dialog {
902 my $part = SL::DB::Part->load_cached($::form->{parts_id}) or die "need parts_id";
903 my $unit = SL::DB::Unit->load_cached($::form->{unit}) or die "need unit";
904 my $stock = $::form->{stock};
905 my $row = $::form->{row};
906 my $item_id = $::form->{item_id};
907 my $qty = _parse_number($::form->{qty_as_number});
909 my $inout = $self->type_data->transfer;
911 my @contents = DO->get_item_availability(parts_id => $part->id);
912 my $stock_info = DO->unpack_stock_information(packed => $stock);
914 $self->merge_stock_data($stock_info, \@contents, $part, $unit);
916 $self->render("delivery_order/stock_dialog", { layout => 0 },
917 WHCONTENTS => $self->order->delivered ? $stock_info : \@contents,
920 do_unit => $unit->unit,
921 delivered => $self->order->delivered,
927 sub action_update_stock_information {
930 my $stock_info = $::form->{stock_info};
931 my $unit = $::form->{unit};
932 my $yaml = SL::YAML::Dump($stock_info);
933 my $stock_qty = $self->calculate_stock_in_out_from_stock_info($unit, $stock_info);
937 stock_qty => $stock_qty,
939 $self->render(\ SL::JSON::to_json($response), { layout => 0, type => 'json', process => 0 });
942 sub merge_stock_data {
943 my ($self, $stock_info, $contents, $part, $unit) = @_;
944 # TODO rewrite to mapping
946 if (!$self->order->delivered) {
947 for my $row (@$contents) {
948 # row here is in parts units. stock is in item units
949 $row->{available_qty} = _format_number($part->unit_obj->convert_to($row->{qty}, $unit));
951 for my $sinfo (@{ $stock_info }) {
952 next if $row->{bin_id} != $sinfo->{bin_id} ||
953 $row->{warehouse_id} != $sinfo->{warehouse_id} ||
954 $row->{chargenumber} ne $sinfo->{chargenumber} ||
955 $row->{bestbefore} ne $sinfo->{bestbefore};
957 $row->{"stock_$_"} = $sinfo->{$_}
958 for qw(qty unit error delivery_order_items_stock_id);
963 for my $sinfo (@{ $stock_info }) {
964 my $bin = SL::DB::Bin->load_cached($sinfo->{bin_id});
965 $sinfo->{warehousedescription} = $bin->warehouse->description;
966 $sinfo->{bindescription} = $bin->description;
967 map { $sinfo->{"stock_$_"} = $sinfo->{$_} } qw(qty unit);
972 # load the second row for one or more items
974 # This action gets the html code for all items second rows by rendering a template for
975 # the second row and sets the html code via client js.
976 sub action_load_second_rows {
979 foreach my $item_id (@{ $::form->{item_ids} }) {
980 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
981 my $item = $self->order->items_sorted->[$idx];
983 $self->js_load_second_row($item, $item_id, 0);
986 $self->js->run('kivi.DeliveryOrder.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
991 # update description, notes and sellprice from master data
992 sub action_update_row_from_master_data {
995 foreach my $item_id (@{ $::form->{item_ids} }) {
996 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
997 my $item = $self->order->items_sorted->[$idx];
998 my $texts = get_part_texts($item->part, $self->order->language_id);
1000 $item->description($texts->{description});
1001 $item->longdescription($texts->{longdescription});
1003 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1006 if ($item->part->is_assortment) {
1007 # add assortment items with price 0, as the components carry the price
1008 $price_src = $price_source->price_from_source("");
1009 $price_src->price(0);
1011 $price_src = $price_source->best_price
1012 ? $price_source->best_price
1013 : $price_source->price_from_source("");
1014 $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate;
1015 $price_src->price(0) if !$price_source->best_price;
1019 $item->sellprice($price_src->price);
1020 $item->active_price_source($price_src);
1023 ->run('kivi.DeliveryOrder.update_sellprice', $item_id, $item->sellprice_as_number)
1024 ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
1025 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
1026 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
1028 if ($self->search_cvpartnumber) {
1029 $self->get_item_cvpartnumber($item);
1030 $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
1034 $self->js_redisplay_line_values;
1036 $self->js->render();
1039 sub action_transfer_stock {
1042 if ($self->order->delivered) {
1043 return $self->js->flash("error", t8('The parts for this order have already been transferred'))->render;
1046 my $inout = $self->type_data->properties('transfer');
1048 my $errors = $self->save;
1051 $self->js->flash('error', $_) for @$errors;
1052 return $self->js->render;
1055 my $order = $self->order;
1057 # TODO move to type data
1058 my $trans_type = $inout eq 'in'
1059 ? SL::DB::Manager::TransferType->find_by(direction => "id", description => "stock")
1060 : SL::DB::Manager::TransferType->find_by(direction => "out", description => "shipped");
1062 my @transfer_requests;
1064 for my $item (@{ $order->items_sorted }) {
1065 for my $stock (@{ $item->delivery_order_stock_entries }) {
1066 my $transfer = SL::DB::Inventory->new_from($stock);
1067 $transfer->trans_type($trans_type);
1068 $transfer->qty($transfer->qty * -1) if $inout eq 'out';
1070 push @transfer_requests, $transfer if defined $transfer->qty && $transfer->qty != 0;
1074 if (!@transfer_requests) {
1075 return $self->js->flash("error", t8("No stock to transfer"))->render;
1078 SL::DB->client->with_transaction(sub {
1079 $_->save for @transfer_requests;
1080 $self->order->update_attributes(delivered => 1);
1084 ->flash("info", t8("Stock transfered"))
1085 ->run('kivi.ActionBar.setDisabled', '#transfer_out_action', t8('The parts for this order have already been transferred'))
1086 ->run('kivi.ActionBar.setDisabled', '#transfer_in_action', t8('The parts for this order have already been transferred'))
1087 ->run('kivi.ActionBar.setDisabled', '#delete_action', t8('The parts for this order have already been transferred'))
1088 ->replaceWith('#data-status-line', delivery_order_status_line($self->order))
1093 sub js_load_second_row {
1094 my ($self, $item, $item_id, $do_parse) = @_;
1097 # Parse values from form (they are formated while rendering (template)).
1098 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1099 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1100 foreach my $var (@{ $item->cvars_by_config }) {
1101 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1103 $item->parse_custom_variable_values;
1106 my $row_as_html = $self->p->render('delivery_order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1109 ->html('#second_row_' . $item_id, $row_as_html)
1110 ->data('#second_row_' . $item_id, 'loaded', 1);
1113 sub js_redisplay_line_values {
1116 my $is_sales = $self->order->is_sales;
1118 # sales orders with margins
1123 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1124 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1125 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1126 ]} @{ $self->order->items_sorted };
1130 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1131 ]} @{ $self->order->items_sorted };
1135 ->run('kivi.DeliveryOrder.redisplay_line_values', $is_sales, \@data);
1138 sub js_redisplay_cvpartnumbers {
1141 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1143 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1146 ->run('kivi.DeliveryOrder.redisplay_cvpartnumbers', \@data);
1149 sub js_reset_order_and_item_ids_after_save {
1153 ->val('#id', $self->order->id)
1154 ->val('#converted_from_oe_id', '')
1155 ->val('#order_' . $self->nr_key(), $self->order->number);
1158 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1159 next if !$self->order->items_sorted->[$idx]->id;
1160 next if $form_item_id !~ m{^new};
1162 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1163 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1164 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1168 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1178 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1179 die "Not a valid type for delivery order";
1182 $self->type($::form->{type});
1188 return $self->type_data->customervendor;
1191 sub init_search_cvpartnumber {
1194 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1195 my $search_cvpartnumber;
1196 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1197 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1199 return $search_cvpartnumber;
1202 sub init_show_update_button {
1205 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1216 sub init_all_price_factors {
1217 SL::DB::Manager::PriceFactor->get_all;
1220 sub init_part_picker_classification_ids {
1223 return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => $self->type_data->part_classification_query) } ];
1229 $::auth->assert($self->type_data->access('view') || 'DOES_NOT_EXIST');
1232 sub check_auth_for_edit {
1235 $::auth->assert($self->type_data->access('edit') || 'DOES_NOT_EXIST');
1238 # build the selection box for contacts
1240 # Needed, if customer/vendor changed.
1241 sub build_contact_select {
1244 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1245 value_key => 'cp_id',
1246 title_key => 'full_name_dep',
1247 default => $self->order->cp_id,
1249 style => 'width: 300px',
1253 # build the selection box for shiptos
1255 # Needed, if customer/vendor changed.
1256 sub build_shipto_select {
1259 select_tag('order.shipto_id',
1260 [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
1261 value_key => 'shipto_id',
1262 title_key => 'displayable_id',
1263 default => $self->order->shipto_id,
1265 style => 'width: 300px',
1269 # build the inputs for the cusom shipto dialog
1271 # Needed, if customer/vendor changed.
1272 sub build_shipto_inputs {
1275 my $content = $self->p->render('common/_ship_to_dialog',
1276 vc_obj => $self->order->customervendor,
1277 cs_obj => $self->order->custom_shipto,
1278 cvars => $self->order->custom_shipto->cvars_by_config,
1279 id_selector => '#order_shipto_id');
1281 div_tag($content, id => 'shipto_inputs');
1284 # render the info line for business
1286 # Needed, if customer/vendor changed.
1287 sub build_business_info_row
1289 $_[0]->p->render('delivery_order/tabs/_business_info_row', SELF => $_[0]);
1296 return if !$::form->{id};
1298 $self->order(SL::DB::DeliveryOrder->new(id => $::form->{id})->load);
1300 # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
1301 # You need a custom shipto object to call cvars_by_config to get the cvars.
1302 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
1304 $self->prepare_stock_info($_) for $self->order->items;
1306 return $self->order;
1309 # load or create a new order object
1311 # And assign changes from the form to this object.
1312 # If the order is loaded from db, check if items are deleted in the form,
1313 # remove them form the object and collect them for removing from db on saving.
1314 # Then create/update items from form (via make_item) and add them.
1318 # add_items adds items to an order with no items for saving, but they cannot
1319 # be retrieved via items until the order is saved. Adding empty items to new
1320 # order here solves this problem.
1322 $order = SL::DB::DeliveryOrder->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1323 $order ||= SL::DB::DeliveryOrder->new(orderitems => [], currency_id => $::instance_conf->get_currency_id(), order_type => $self->type_data->validate($::form->{type}));
1325 my $cv_id_method = $self->cv . '_id';
1326 if (!$::form->{id} && $::form->{$cv_id_method}) {
1327 $order->$cv_id_method($::form->{$cv_id_method});
1328 setup_order_from_cv($order);
1331 my $form_orderitems = delete $::form->{order}->{orderitems};
1333 $order->assign_attributes(%{$::form->{order}});
1335 $self->setup_custom_shipto_from_form($order, $::form);
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);
1356 $self->prepare_stock_info($_) for $order->items, @items;
1358 $order->add_items(grep {!$_->id} @items);
1363 # create or update items from form
1365 # Make item objects from form values. For items already existing read from db.
1366 # Create a new item else. And assign attributes.
1368 my ($record, $attr) = @_;
1371 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1373 my $is_new = !$item;
1375 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1376 # they cannot be retrieved via custom_variables until the order/orderitem is
1377 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1378 $item ||= SL::DB::DeliveryOrderItem->new(custom_variables => []);
1381 if (my $stock_info = delete $attr->{stock_info}) {
1382 my %existing = map { $_->id => $_ } $item->delivery_order_stock_entries;
1385 for my $line (@{ DO->unpack_stock_information(packed => $stock_info) }) {
1386 # lookup existing or make new
1387 my $obj = delete $existing{$line->{delivery_order_items_stock_id}}
1388 // SL::DB::DeliveryOrderItemsStock->new;
1391 $obj->$_($line->{$_}) for qw(bin_id warehouse_id chargenumber qty unit);
1392 $obj->bestbefore_as_date($line->{bestfbefore})
1393 if $line->{bestbefore} && $::instance_conf->get_show_bestbefore;
1394 push @save, $obj if $obj->qty;
1397 $item->delivery_order_stock_entries(@save);
1400 $item->assign_attributes(%$attr);
1403 my $texts = get_part_texts($item->part, $record->language_id);
1404 $item->longdescription($texts->{longdescription}) if !defined $attr->{longdescription};
1405 $item->project_id($record->globalproject_id) if !defined $attr->{project_id};
1406 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number};
1414 # This is used to add one item
1416 my ($record, $attr) = @_;
1418 my $item = SL::DB::DeliveryOrderItem->new;
1420 # Remove attributes where the user left or set the inputs empty.
1421 # So these attributes will be undefined and we can distinguish them
1422 # from zero later on.
1423 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1424 delete $attr->{$_} if $attr->{$_} eq '';
1427 $item->assign_attributes(%$attr);
1429 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1430 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1432 $item->unit($part->unit) if !$item->unit;
1435 if ( $part->is_assortment ) {
1436 # add assortment items with price 0, as the components carry the price
1437 $price_src = $price_source->price_from_source("");
1438 $price_src->price(0);
1439 } elsif (defined $item->sellprice) {
1440 $price_src = $price_source->price_from_source("");
1441 $price_src->price($item->sellprice);
1443 $price_src = $price_source->best_price
1444 ? $price_source->best_price
1445 : $price_source->price_from_source("");
1446 $price_src->price(0) if !$price_source->best_price;
1450 if (defined $item->discount) {
1451 $discount_src = $price_source->discount_from_source("");
1452 $discount_src->discount($item->discount);
1454 $discount_src = $price_source->best_discount
1455 ? $price_source->best_discount
1456 : $price_source->discount_from_source("");
1457 $discount_src->discount(0) if !$price_source->best_discount;
1461 $new_attr{part} = $part;
1462 $new_attr{description} = $part->description if ! $item->description;
1463 $new_attr{qty} = 1.0 if ! $item->qty;
1464 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1465 $new_attr{sellprice} = $price_src->price;
1466 $new_attr{discount} = $discount_src->discount;
1467 $new_attr{active_price_source} = $price_src;
1468 $new_attr{active_discount_source} = $discount_src;
1469 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1470 $new_attr{project_id} = $record->globalproject_id;
1471 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1473 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1474 # they cannot be retrieved via custom_variables until the order/orderitem is
1475 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1476 $new_attr{custom_variables} = [];
1478 my $texts = get_part_texts($part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription});
1480 $item->assign_attributes(%new_attr, %{ $texts });
1485 sub prepare_stock_info {
1486 my ($self, $item) = @_;
1488 $item->{stock_info} = SL::YAML::Dump([
1490 delivery_order_items_stock_id => $_->id,
1492 warehouse_id => $_->warehouse_id,
1493 bin_id => $_->bin_id,
1494 chargenumber => $_->chargenumber,
1496 }, $item->delivery_order_stock_entries
1500 sub setup_order_from_cv {
1503 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id));
1505 $order->intnotes($order->customervendor->notes);
1507 if ($order->is_sales) {
1508 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1509 $order->taxincluded(defined($order->customer->taxincluded_checked)
1510 ? $order->customer->taxincluded_checked
1511 : $::myconfig{taxincluded_checked});
1516 # setup custom shipto from form
1518 # The dialog returns form variables starting with 'shipto' and cvars starting
1519 # with 'shiptocvar_'.
1520 # Mark it to be deleted if a shipto from master data is selected
1521 # (i.e. order has a shipto).
1522 # Else, update or create a new custom shipto. If the fields are empty, it
1523 # will not be saved on save.
1524 sub setup_custom_shipto_from_form {
1525 my ($self, $order, $form) = @_;
1527 if ($order->shipto) {
1528 $self->is_custom_shipto_to_delete(1);
1530 my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1532 my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
1533 my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
1535 $custom_shipto->assign_attributes(%$shipto_attrs);
1536 $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
1540 # get data for saving, printing, ..., that is not changed in the form
1542 # Only cvars for now.
1543 sub get_unalterable_data {
1546 foreach my $item (@{ $self->order->items }) {
1547 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1548 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1549 foreach my $var (@{ $item->cvars_by_config }) {
1550 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1552 $item->parse_custom_variable_values;
1558 # And remove related files in the spool directory
1563 my $db = $self->order->db;
1565 $db->with_transaction(
1567 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1568 $self->order->delete;
1569 my $spool = $::lx_office_conf{paths}->{spool};
1570 unlink map { "$spool/$_" } @spoolfiles if $spool;
1572 $self->save_history('DELETED');
1575 }) || push(@{$errors}, $db->error);
1582 # And delete items that are deleted in the form.
1587 my $db = $self->order->db;
1589 $db->with_transaction(sub {
1590 # delete custom shipto if it is to be deleted or if it is empty
1591 if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
1592 $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
1593 $self->order->custom_shipto(undef);
1596 SL::DB::DeliveryOrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1597 $self->order->save(cascade => 1);
1600 if ($::form->{converted_from_oe_id}) {
1601 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1602 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1603 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1604 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/ && $self->order->is_type(PURCHASE_DELIVERY_ORDER_TYPE);
1605 $src->link_to_record($self->order);
1607 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1609 foreach (@{ $self->order->items_sorted }) {
1610 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1612 SL::DB::RecordLink->new(from_table => 'orderitems',
1613 from_id => $from_id,
1614 to_table => 'orderitems',
1622 $self->save_history('SAVED');
1625 }) || push(@{$errors}, $db->error);
1630 sub workflow_sales_or_request_for_quotation {
1634 my $errors = $self->save();
1636 if (scalar @{ $errors }) {
1637 $self->js->flash('error', $_) for @{ $errors };
1638 return $self->js->render();
1641 my $destination_type = $self->type_data->workflow("to_quotation_type");
1643 $self->order(SL::DB::DeliveryOrder->new_from($self->order, destination_type => $destination_type));
1644 $self->{converted_from_oe_id} = delete $::form->{id};
1646 # set item ids to new fake id, to identify them as new items
1647 foreach my $item (@{$self->order->items_sorted}) {
1648 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1652 $::form->{type} = $destination_type;
1653 $self->type($self->init_type);
1654 $self->cv ($self->init_cv);
1657 $self->get_unalterable_data();
1658 $self->pre_render();
1660 # trigger rendering values for second row as hidden, because they
1661 # are loaded only on demand. So we need to keep the values from the
1663 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1666 'delivery_order/form',
1667 title => $self->get_title_for('edit'),
1668 %{$self->{template_args}}
1672 sub workflow_sales_or_purchase_order {
1676 my $errors = $self->save();
1678 if (scalar @{ $errors }) {
1679 $self->js->flash('error', $_) foreach @{ $errors };
1680 return $self->js->render();
1683 my $destination_type = $self->type_data->workflow("to_order_type");
1685 # check for direct delivery
1686 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
1688 if ($self->type_data->workflow("to_order_copy_shipto") && $::form->{use_shipto} && $self->order->shipto) {
1689 $custom_shipto = $self->order->shipto->clone('SL::DB::DeliveryOrder');
1692 $self->order(SL::DB::DeliveryOrder->new_from($self->order, destination_type => $destination_type));
1693 $self->{converted_from_oe_id} = delete $::form->{id};
1695 # set item ids to new fake id, to identify them as new items
1696 foreach my $item (@{$self->order->items_sorted}) {
1697 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1700 if ($self->type_data->workflow("to_order_copy_shipto")) {
1701 if ($::form->{use_shipto}) {
1702 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
1704 # remove any custom shipto if not wanted
1705 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1710 $::form->{type} = $destination_type;
1711 $self->type($self->init_type);
1712 $self->cv ($self->init_cv);
1715 $self->get_unalterable_data();
1716 $self->pre_render();
1718 # trigger rendering values for second row as hidden, because they
1719 # are loaded only on demand. So we need to keep the values from the
1721 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1724 'delivery_order/form',
1725 title => $self->get_title_for('edit'),
1726 %{$self->{template_args}}
1733 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1734 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
1735 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1736 $self->{all_languages} = SL::DB::Manager::Language->get_all_sorted();
1737 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1740 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1743 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1745 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1746 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1747 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1748 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1750 my $print_form = Form->new('');
1751 $print_form->{type} = $self->type;
1752 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1753 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1754 form => $print_form,
1755 options => {dialog_name_prefix => 'print_options.',
1759 no_opendocument => 0,
1763 foreach my $item (@{$self->order->orderitems}) {
1764 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1765 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1766 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1769 if ($self->order->${\ $self->type_data->nr_key } && $::instance_conf->get_webdav) {
1770 my $webdav = SL::Webdav->new(
1771 type => $self->type,
1772 number => $self->order->number,
1774 my @all_objects = $webdav->get_all_objects;
1775 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1777 link => File::Spec->catfile($_->full_filedescriptor),
1781 $self->{template_args}{in_out} = $self->type_data->transfer;
1783 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1785 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.DeliveryOrder kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
1786 calculate_qty kivi.Validator follow_up show_history);
1787 $self->setup_edit_action_bar;
1790 sub setup_edit_action_bar {
1791 my ($self, %params) = @_;
1793 my $deletion_allowed = $self->type_data->show_menu("delete");
1794 my $may_edit_create = $::auth->assert($self->type_data->access('edit') || 'DOES_NOT_EXIST', 1);
1796 for my $bar ($::request->layout->get('actionbar')) {
1801 call => [ 'kivi.DeliveryOrder.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1802 $::instance_conf->get_order_warn_no_deliverydate,
1804 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1808 call => [ 'kivi.DeliveryOrder.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1809 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
1810 : $self->type eq 'supplier_delivery_order' ? t8('Need a workflow for Supplier Delivery Order')
1811 : !$self->order->id ? t8('This object has not been saved yet.')
1814 ], # end of combobox "Save"
1821 t8('Save and Quotation'),
1822 submit => [ '#order_form', { action => "DeliveryOrder/sales_quotation" } ],
1823 only_if => $self->type_data->show_menu("save_and_quotation"),
1824 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1828 submit => [ '#order_form', { action => "DeliveryOrder/request_for_quotation" } ],
1829 only_if => $self->type_data->show_menu("save_and_rfq"),
1830 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1833 t8('Save and Sales Order'),
1834 submit => [ '#order_form', { action => "DeliveryOrder/sales_order" } ],
1835 only_if => $self->type_data->show_menu("save_and_sales_order"),
1836 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1839 t8('Save and Purchase Order'),
1840 call => [ 'kivi.DeliveryOrder.purchase_order_check_for_direct_delivery' ],
1841 only_if => $self->type_data->show_menu("save_and_purchase_order"),
1842 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1845 t8('Save and Delivery Order'),
1846 call => [ 'kivi.DeliveryOrder.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1847 $::instance_conf->get_order_warn_no_deliverydate,
1849 only_if => $self->type_data->show_menu("save_and_delivery_order"),
1850 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1853 t8('Save and Invoice'),
1854 call => [ 'kivi.DeliveryOrder.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1855 only_if => $self->type_data->show_menu("save_and_invoice"),
1856 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1859 t8('Save and AP Transaction'),
1860 call => [ 'kivi.DeliveryOrder.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
1861 only_if => $self->type_data->show_menu("save_and_ap_transaction"),
1862 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1865 ], # end of combobox "Workflow"
1872 t8('Save and preview PDF'),
1873 call => [ 'kivi.DeliveryOrder.save', 'preview_pdf', $::instance_conf->get_order_warn_duplicate_parts,
1874 $::instance_conf->get_order_warn_no_deliverydate,
1876 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1879 t8('Save and print'),
1880 call => [ 'kivi.DeliveryOrder.show_print_options', $::instance_conf->get_order_warn_duplicate_parts,
1881 $::instance_conf->get_order_warn_no_deliverydate,
1883 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.') : undef,
1886 t8('Save and E-mail'),
1887 id => 'save_and_email_action',
1888 call => [ 'kivi.DeliveryOrder.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts,
1889 $::instance_conf->get_order_warn_no_deliverydate,
1891 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
1892 : !$self->order->id ? t8('This object has not been saved yet.')
1896 t8('Download attachments of all parts'),
1897 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1898 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
1899 : !$self->order->id ? t8('This object has not been saved yet.')
1901 only_if => $::instance_conf->get_doc_storage,
1903 ], # end of combobox "Export"
1907 id => 'delete_action',
1908 call => [ 'kivi.DeliveryOrder.delete_order' ],
1909 confirm => $::locale->text('Do you really want to delete this object?'),
1910 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
1911 : !$self->order->id ? t8('This object has not been saved yet.')
1912 : $self->order->delivered ? t8('The parts for this order have already been transferred')
1914 only_if => $self->type_data->show_menu("delete"),
1920 id => 'transfer_out_action',
1921 call => [ 'kivi.DeliveryOrder.save', 'transfer_stock' ],
1922 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
1923 : !$self->order->id ? t8('This object has not been saved yet.')
1924 : $self->order->delivered ? t8('The parts for this order have already been transferred')
1926 only_if => $self->type_data->properties('transfer') eq 'out',
1927 confirm => t8('Do you really want to transfer the stock and set this order to delivered?'),
1931 id => 'transfer_in_action',
1932 call => [ 'kivi.DeliveryOrder.save', 'transfer_stock' ],
1933 disabled => !$may_edit_create ? t8('You do not have the permissions to access this function.')
1934 : !$self->order->id ? t8('This object has not been saved yet.')
1935 : $self->order->delivered ? t8('The parts for this order have already been transferred')
1937 only_if => $self->type_data->properties('transfer') eq 'in',
1938 confirm => t8('Do you really want to transfer the stock and set this order to delivered?'),
1948 call => [ 'kivi.DeliveryOrder.follow_up_window' ],
1949 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1950 only_if => $::auth->assert('productivity', 1),
1954 call => [ 'set_history_window', $self->order->id, 'id' ],
1955 disabled => !$self->order->id ? t8('This record has not been saved yet.') : undef,
1957 ], # end of combobox "more"
1963 my ($order, $pdf_ref, $params) = @_;
1967 my $print_form = Form->new('');
1968 $print_form->{type} = $order->type;
1969 $print_form->{formname} = $params->{formname} || $order->type;
1970 $print_form->{format} = $params->{format} || 'pdf';
1971 $print_form->{media} = $params->{media} || 'file';
1972 $print_form->{groupitems} = $params->{groupitems};
1973 $print_form->{printer_id} = $params->{printer_id};
1974 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1976 $order->language($params->{language});
1977 $order->flatten_to_form($print_form, format_amounts => 1);
1981 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
1982 $template_ext = 'odt';
1983 $template_type = 'OpenDocument';
1986 # search for the template
1987 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1988 name => $print_form->{formname},
1989 extension => $template_ext,
1990 email => $print_form->{media} eq 'email',
1991 language => $params->{language},
1992 printer_id => $print_form->{printer_id},
1995 if (!defined $template_file) {
1996 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);
1999 return @errors if scalar @errors;
2001 $print_form->throw_on_error(sub {
2003 $print_form->prepare_for_printing;
2005 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
2006 format => $print_form->{format},
2007 template_type => $template_type,
2008 template => $template_file,
2009 variables => $print_form,
2010 variable_content_types => {
2011 longdescription => 'html',
2012 partnotes => 'html',
2017 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
2023 sub get_files_for_email_dialog {
2026 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
2028 return %files if !$::instance_conf->get_doc_storage;
2030 if ($self->order->id) {
2031 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
2032 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
2033 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
2034 $files{project_files} = [ SL::File->get_all( object_id => $self->order->globalproject_id, object_type => 'project', file_type => 'attachment') ];
2038 uniq_by { $_->{id} }
2040 +{ id => $_->part->id,
2041 partnumber => $_->part->partnumber }
2042 } @{$self->order->items_sorted};
2044 foreach my $part (@parts) {
2045 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
2046 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
2049 foreach my $key (keys %files) {
2050 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
2057 my ($self, $action) = @_;
2059 return '' if none { lc($action)} qw(add edit);
2060 return $self->type_data->text($action);
2063 sub get_item_cvpartnumber {
2064 my ($self, $item) = @_;
2066 return if !$self->search_cvpartnumber;
2067 return if !$self->order->customervendor;
2069 if ($self->cv eq 'vendor') {
2070 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
2071 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
2072 } elsif ($self->cv eq 'customer') {
2073 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
2074 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
2078 sub get_part_texts {
2079 my ($part_or_id, $language_or_id, %defaults) = @_;
2081 my $part = ref($part_or_id) ? $part_or_id : SL::DB::Part->load_cached($part_or_id);
2082 my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id;
2084 description => $defaults{description} // $part->description,
2085 longdescription => $defaults{longdescription} // $part->notes,
2088 return $texts unless $language_id;
2090 my $translation = SL::DB::Manager::Translation->get_first(
2092 parts_id => $part->id,
2093 language_id => $language_id,
2096 $texts->{description} = $translation->translation if $translation && $translation->translation;
2097 $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription;
2103 return $_[0]->type_data->nr_key;
2106 sub save_and_redirect_to {
2107 my ($self, %params) = @_;
2109 my $errors = $self->save();
2111 if (scalar @{ $errors }) {
2112 $self->js->flash('error', $_) foreach @{ $errors };
2113 return $self->js->render();
2116 flash_later('info', $self->type_data->text("saved"));
2118 $self->redirect_to(%params, id => $self->order->id);
2122 my ($self, $addition) = @_;
2124 my $number_type = $self->nr_key;
2125 my $snumbers = $number_type . '_' . $self->order->$number_type;
2127 SL::DB::History->new(
2128 trans_id => $self->order->id,
2129 employee_id => SL::DB::Manager::Employee->current->id,
2130 what_done => $self->order->type,
2131 snumbers => $snumbers,
2132 addition => $addition,
2136 sub store_pdf_to_webdav_and_filemanagement {
2137 my($order, $content, $filename) = @_;
2141 # copy file to webdav folder
2142 if ($order->number && $::instance_conf->get_webdav_documents) {
2143 my $webdav = SL::Webdav->new(
2144 type => $order->type,
2145 number => $order->number,
2147 my $webdav_file = SL::Webdav::File->new(
2149 filename => $filename,
2152 $webdav_file->store(data => \$content);
2155 push @errors, t8('Storing PDF to webdav folder failed: #1', $@);
2158 if ($order->id && $::instance_conf->get_doc_storage) {
2160 SL::File->save(object_id => $order->id,
2161 object_type => $order->type,
2162 mime_type => 'application/pdf',
2163 source => 'created',
2164 file_type => 'document',
2165 file_name => $filename,
2166 file_contents => $content);
2169 push @errors, t8('Storing PDF in storage backend failed: #1', $@);
2176 sub calculate_stock_in_out_from_stock_info {
2177 my ($self, $unit, $stock_info) = @_;
2179 return "" if !$unit;
2181 my %units_by_name = map { $_->name => $_ } @{ SL::DB::Manager::Unit->get_all };
2183 my $sum = sum0 map {
2184 $units_by_name{$_->{unit}}->convert_to($_->{qty}, $units_by_name{$unit})
2187 my $content = _format_number($sum, 2) . ' ' . $unit;
2192 sub calculate_stock_in_out {
2193 my ($self, $item, $stock_info) = @_;
2195 return "" if !$item->part || !$item->part->unit || !$item->unit;
2197 my $sum = sum0 map {
2198 $_->unit_obj->convert_to($_->qty, $item->unit_obj)
2199 } $item->delivery_order_stock_entries;
2201 my $content = _format_number($sum, 2);
2206 sub init_type_data {
2207 SL::Controller::DeliveryOrder::TypeData->new($_[0]);
2210 sub init_valid_types {
2211 $_[0]->type_data->valid_types;
2222 SL::Controller::Order - controller for orders
2226 This is a new form to enter orders, completely rewritten with the use
2227 of controller and java script techniques.
2229 The aim is to provide the user a better experience and a faster workflow. Also
2230 the code should be more readable, more reliable and better to maintain.
2238 One input row, so that input happens every time at the same place.
2242 Use of pickers where possible.
2246 Possibility to enter more than one item at once.
2250 Item list in a scrollable area, so that the workflow buttons stay at
2255 Reordering item rows with drag and drop is possible. Sorting item rows is
2256 possible (by partnumber, description, qty, sellprice and discount for now).
2260 No C<update> is necessary. All entries and calculations are managed
2261 with ajax-calls and the page only reloads on C<save>.
2265 User can see changes immediately, because of the use of java script
2276 =item * C<SL/Controller/Order.pm>
2280 =item * C<template/webpages/delivery_order/form.html>
2284 =item * C<template/webpages/delivery_order/tabs/basic_data.html>
2286 Main tab for basic_data.
2288 This is the only tab here for now. "linked records" and "webdav" tabs are
2289 reused from generic code.
2293 =item * C<template/webpages/delivery_order/tabs/_business_info_row.html>
2295 For displaying information on business type
2297 =item * C<template/webpages/delivery_order/tabs/_item_input.html>
2299 The input line for items
2301 =item * C<template/webpages/delivery_order/tabs/_row.html>
2303 One row for already entered items
2305 =item * C<template/webpages/delivery_order/tabs/_tax_row.html>
2307 Displaying tax information
2311 =item * C<js/kivi.DeliveryOrder.js>
2313 java script functions
2323 =item * price sources: little symbols showing better price / better discount
2325 =item * select units in input row?
2327 =item * check for direct delivery (workflow sales order -> purchase order)
2329 =item * access rights
2331 =item * display weights
2335 =item * optional client/user behaviour
2337 (transactions has to be set - department has to be set -
2338 force project if enabled in client config - transport cost reminder)
2342 =head1 KNOWN BUGS AND CAVEATS
2348 Customer discount is not displayed as a valid discount in price source popup
2349 (this might be a bug in price sources)
2351 (I cannot reproduce this (Bernd))
2355 No indication that <shift>-up/down expands/collapses second row.
2359 Inline creation of parts is not currently supported
2363 Table header is not sticky in the scrolling area.
2367 Sorting does not include C<position>, neither does reordering.
2369 This behavior was implemented intentionally. But we can discuss, which behavior
2370 should be implemented.
2374 =head1 To discuss / Nice to have
2380 How to expand/collapse second row. Now it can be done clicking the icon or
2385 Possibility to select PriceSources in input row?
2389 This controller uses a (changed) copy of the template for the PriceSource
2390 dialog. Maybe there could be used one code source.
2394 Rounding-differences between this controller (PriceTaxCalculator) and the old
2395 form. This is not only a problem here, but also in all parts using the PTC.
2396 There exists a ticket and a patch. This patch should be testet.
2400 An indicator, if the actual inputs are saved (like in an
2401 editor or on text processing application).
2405 A warning when leaving the page without saveing unchanged inputs.
2412 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>