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_units _parse_number);
8 use SL::Presenter::Tag qw(select_tag hidden_tag div_tag);
9 use SL::Locale::String qw(t8);
10 use SL::SessionFile::Random;
15 use SL::Util qw(trim);
23 use SL::DB::PartClassification;
24 use SL::DB::PartsGroup;
27 use SL::DB::RecordLink;
29 use SL::DB::Translation;
30 use SL::DB::TransferType;
32 use SL::Helper::CreatePDF qw(:all);
33 use SL::Helper::PrintOptions;
34 use SL::Helper::ShippedQty;
35 use SL::Helper::UserPreferences::PositionsScrollbar;
36 use SL::Helper::UserPreferences::UpdatePositions;
38 use SL::Controller::Helper::GetModels;
39 use SL::Controller::DeliveryOrder::TypeData qw(:types);
41 use List::Util qw(first sum0);
42 use List::UtilsBy qw(sort_by uniq_by);
43 use List::MoreUtils qw(any none pairwise first_index);
44 use English qw(-no_match_vars);
49 use Rose::Object::MakeMethods::Generic
51 scalar => [ qw(item_ids_to_delete is_custom_shipto_to_delete) ],
52 '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) ],
57 __PACKAGE__->run_before('check_auth',
58 except => [ qw(update_stock_information) ]);
60 __PACKAGE__->run_before('get_unalterable_data',
61 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
72 $self->order->transdate(DateTime->now_local());
73 $self->type_data->set_reqdate_by_type;
78 'delivery_order/form',
79 title => $self->get_title_for('add'),
80 %{$self->{template_args}}
84 sub action_add_from_order {
86 # this interfers with init_order
87 $self->{converted_from_oe_id} = delete $::form->{id};
89 $self->type_data->validate($::form->{type});
91 my $order = SL::DB::Order->new(id => $self->{converted_from_oe_id})->load;
93 $self->order(SL::DB::DeliveryOrder->new_from($order, type => $::form->{type}));
98 # edit an existing order
106 # this is to edit an order from an unsaved order object
108 # set item ids to new fake id, to identify them as new items
109 foreach my $item (@{$self->order->items_sorted}) {
110 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
112 # trigger rendering values for second row as hidden, because they
113 # are loaded only on demand. So we need to keep the values from
115 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
120 'delivery_order/form',
121 title => $self->get_title_for('edit'),
122 %{$self->{template_args}}
126 # edit a collective order (consisting of one or more existing orders)
127 sub action_edit_collective {
131 my @multi_ids = map {
132 $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1}
133 } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form;
135 # fall back to add if no ids are given
136 if (scalar @multi_ids == 0) {
141 # fall back to save as new if only one id is given
142 if (scalar @multi_ids == 1) {
143 $self->order(SL::DB::DeliveryOrder->new(id => $multi_ids[0])->load);
144 $self->action_save_as_new();
148 # make new order from given orders
149 my @multi_orders = map { SL::DB::DeliveryOrder->new(id => $_)->load } @multi_ids;
150 $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders;
151 $self->order(SL::DB::DeliveryOrder->new_from_multi(\@multi_orders, sort_sources_by => 'transdate'));
153 $self->action_edit();
160 my $errors = $self->delete();
162 if (scalar @{ $errors }) {
163 $self->js->flash('error', $_) foreach @{ $errors };
164 return $self->js->render();
167 flash_later('info', $self->type_data->text("delete"));
169 my @redirect_params = (
174 $self->redirect_to(@redirect_params);
181 my $errors = $self->save();
183 if (scalar @{ $errors }) {
184 $self->js->flash('error', $_) foreach @{ $errors };
185 return $self->js->render();
188 flash_later('info', $self->type_data->text("saved"));
190 my @redirect_params = (
193 id => $self->order->id,
196 $self->redirect_to(@redirect_params);
199 # save the order as new document an open it for edit
200 sub action_save_as_new {
203 my $order = $self->order;
206 $self->js->flash('error', t8('This object has not been saved yet.'));
207 return $self->js->render();
210 # load order from db to check if values changed
211 my $saved_order = SL::DB::DeliveryOrder->new(id => $order->id)->load;
214 # Lets assign a new number if the user hasn't changed the previous one.
215 # If it has been changed manually then use it as-is.
216 $new_attrs{number} = (trim($order->number) eq $saved_order->number)
218 : trim($order->number);
220 # Clear transdate unless changed
221 $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
222 ? DateTime->today_local
225 # Set new reqdate unless changed if it is enabled in client config
226 $new_attrs{reqdate} = $self->type_data->get_reqdate_by_type($order->reqdate, $saved_order->reqdate);
229 $new_attrs{employee} = SL::DB::Manager::Employee->current;
231 # Create new record from current one
232 $self->order(SL::DB::DeliveryOrder->new_from($order, destination_type => $order->type, attributes => \%new_attrs));
234 # no linked records on save as new
235 delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
238 $self->action_save();
243 # This is called if "print" is pressed in the print dialog.
244 # If PDF creation was requested and succeeded, the pdf is offered for download
245 # via send_file (which uses ajax in this case).
249 my $errors = $self->save();
251 if (scalar @{ $errors }) {
252 $self->js->flash('error', $_) foreach @{ $errors };
253 return $self->js->render();
256 $self->js_reset_order_and_item_ids_after_save;
258 my $format = $::form->{print_options}->{format};
259 my $media = $::form->{print_options}->{media};
260 my $formname = $::form->{print_options}->{formname};
261 my $copies = $::form->{print_options}->{copies};
262 my $groupitems = $::form->{print_options}->{groupitems};
263 my $printer_id = $::form->{print_options}->{printer_id};
265 # only pdf and opendocument by now
266 if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf)) {
267 return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
270 # only screen or printer by now
271 if (none { $media eq $_ } qw(screen printer)) {
272 return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
275 # create a form for generate_attachment_filename
276 my $form = Form->new;
277 $form->{$self->nr_key()} = $self->order->number;
278 $form->{type} = $self->type;
279 $form->{format} = $format;
280 $form->{formname} = $formname;
281 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
282 my $pdf_filename = $form->generate_attachment_filename();
285 my @errors = generate_pdf($self->order, \$pdf, { format => $format,
286 formname => $formname,
287 language => $self->order->language,
288 printer_id => $printer_id,
289 groupitems => $groupitems });
290 if (scalar @errors) {
291 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
294 if ($media eq 'screen') {
296 $self->js->flash('info', t8('The PDF has been created'));
299 type => SL::MIME->mime_type_from_ext($pdf_filename),
300 name => $pdf_filename,
304 } elsif ($media eq 'printer') {
306 my $printer_id = $::form->{print_options}->{printer_id};
307 SL::DB::Printer->new(id => $printer_id)->load->print_document(
312 $self->js->flash('info', t8('The PDF has been printed'));
315 my @warnings = store_pdf_to_webdav_and_filemanagement($self->order, $pdf, $pdf_filename);
316 if (scalar @warnings) {
317 $self->js->flash('warning', $_) for @warnings;
320 $self->save_history('PRINTED');
323 ->run('kivi.ActionBar.setEnabled', '#save_and_email_action')
326 sub action_preview_pdf {
329 my $errors = $self->save();
330 if (scalar @{ $errors }) {
331 $self->js->flash('error', $_) foreach @{ $errors };
332 return $self->js->render();
335 $self->js_reset_order_and_item_ids_after_save;
338 my $media = 'screen';
339 my $formname = $self->type;
342 # create a form for generate_attachment_filename
343 my $form = Form->new;
344 $form->{$self->nr_key()} = $self->order->number;
345 $form->{type} = $self->type;
346 $form->{format} = $format;
347 $form->{formname} = $formname;
348 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
349 my $pdf_filename = $form->generate_attachment_filename();
352 my @errors = generate_pdf($self->order, \$pdf, { format => $format,
353 formname => $formname,
354 language => $self->order->language,
356 if (scalar @errors) {
357 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
359 $self->save_history('PREVIEWED');
360 $self->js->flash('info', t8('The PDF has been previewed'));
364 type => SL::MIME->mime_type_from_ext($pdf_filename),
365 name => $pdf_filename,
370 # open the email dialog
371 sub action_save_and_show_email_dialog {
374 my $errors = $self->save();
376 if (scalar @{ $errors }) {
377 $self->js->flash('error', $_) foreach @{ $errors };
378 return $self->js->render();
381 my $cv_method = $self->cv;
383 if (!$self->order->$cv_method) {
384 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'))
389 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
390 $email_form->{to} ||= $self->order->$cv_method->email;
391 $email_form->{cc} = $self->order->$cv_method->cc;
392 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
393 # Todo: get addresses from shipto, if any
395 my $form = Form->new;
396 $form->{$self->nr_key()} = $self->order->number;
397 $form->{cusordnumber} = $self->order->cusordnumber;
398 $form->{formname} = $self->type;
399 $form->{type} = $self->type;
400 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
401 $form->{language_id} = $self->order->language->id if $self->order->language;
402 $form->{format} = 'pdf';
403 $form->{cp_id} = $self->order->contact->cp_id if $self->order->contact;
405 $email_form->{subject} = $form->generate_email_subject();
406 $email_form->{attachment_filename} = $form->generate_attachment_filename();
407 $email_form->{message} = $form->generate_email_body();
408 $email_form->{js_send_function} = 'kivi.Order.send_email()';
410 my %files = $self->get_files_for_email_dialog();
411 $self->{all_employees} = SL::DB::Manager::Employee->get_all(query => [ deleted => 0 ]);
412 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
413 email_form => $email_form,
414 show_bcc => $::auth->assert('email_bcc', 'may fail'),
416 is_customer => $self->type_data->is_customer,
417 ALL_EMPLOYEES => $self->{all_employees},
421 ->run('kivi.Order.show_email_dialog', $dialog_html)
428 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
429 sub action_send_email {
432 my $errors = $self->save();
434 if (scalar @{ $errors }) {
435 $self->js->run('kivi.Order.close_email_dialog');
436 $self->js->flash('error', $_) foreach @{ $errors };
437 return $self->js->render();
440 $self->js_reset_order_and_item_ids_after_save;
442 my $email_form = delete $::form->{email_form};
443 my %field_names = (to => 'email');
445 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
447 # for Form::cleanup which may be called in Form::send_email
448 $::form->{cwd} = getcwd();
449 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
451 $::form->{$_} = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
452 $::form->{media} = 'email';
454 if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) {
456 my @errors = generate_pdf($self->order, \$pdf, {media => $::form->{media},
457 format => $::form->{print_options}->{format},
458 formname => $::form->{print_options}->{formname},
459 language => $self->order->language,
460 printer_id => $::form->{print_options}->{printer_id},
461 groupitems => $::form->{print_options}->{groupitems}});
462 if (scalar @errors) {
463 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
466 my @warnings = store_pdf_to_webdav_and_filemanagement($self->order, $pdf, $::form->{attachment_filename});
467 if (scalar @warnings) {
468 flash_later('warning', $_) for @warnings;
471 my $sfile = SL::SessionFile::Random->new(mode => "w");
472 $sfile->fh->print($pdf);
475 $::form->{tmpfile} = $sfile->file_name;
476 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
479 $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a linked record to the mail
480 $::form->send_email(\%::myconfig, 'pdf');
483 my $intnotes = $self->order->intnotes;
484 $intnotes .= "\n\n" if $self->order->intnotes;
485 $intnotes .= t8('[email]') . "\n";
486 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
487 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
488 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
489 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
490 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
491 $intnotes .= t8('Message') . ": " . $::form->{message};
493 $self->order->update_attributes(intnotes => $intnotes);
495 $self->save_history('MAILED');
497 flash_later('info', t8('The email has been sent.'));
499 my @redirect_params = (
502 id => $self->order->id,
505 $self->redirect_to(@redirect_params);
508 # save the order and redirect to the frontend subroutine for a new
510 sub action_save_and_delivery_order {
513 $self->save_and_redirect_to(
514 controller => 'oe.pl',
515 action => 'oe_delivery_order_from_order',
519 # save the order and redirect to the frontend subroutine for a new
521 sub action_save_and_invoice {
524 $self->save_and_redirect_to(
525 controller => 'oe.pl',
526 action => 'oe_invoice_from_order',
530 # workflow from sales order to sales quotation
531 sub action_sales_quotation {
532 $_[0]->workflow_sales_or_request_for_quotation();
535 # workflow from sales order to sales quotation
536 sub action_request_for_quotation {
537 $_[0]->workflow_sales_or_request_for_quotation();
540 # workflow from sales quotation to sales order
541 sub action_sales_order {
542 $_[0]->workflow_sales_or_purchase_order();
545 # workflow from rfq to purchase order
546 sub action_purchase_order {
547 $_[0]->workflow_sales_or_purchase_order();
550 # workflow from purchase order to ap transaction
551 sub action_save_and_ap_transaction {
554 $self->save_and_redirect_to(
555 controller => 'ap.pl',
556 action => 'add_from_purchase_order',
560 # set form elements in respect to a changed customer or vendor
562 # This action is called on an change of the customer/vendor picker.
563 sub action_customer_vendor_changed {
566 setup_order_from_cv($self->order);
568 my $cv_method = $self->cv;
570 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
571 $self->js->show('#cp_row');
573 $self->js->hide('#cp_row');
576 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
577 $self->js->show('#shipto_selection');
579 $self->js->hide('#shipto_selection');
582 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
585 ->replaceWith('#order_cp_id', $self->build_contact_select)
586 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
587 ->replaceWith('#shipto_inputs ', $self->build_shipto_inputs)
588 ->replaceWith('#business_info_row', $self->build_business_info_row)
589 ->val( '#order_taxzone_id', $self->order->taxzone_id)
590 ->val( '#order_taxincluded', $self->order->taxincluded)
591 ->val( '#order_currency_id', $self->order->currency_id)
592 ->val( '#order_payment_id', $self->order->payment_id)
593 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
594 ->val( '#order_intnotes', $self->order->intnotes)
595 ->val( '#order_language_id', $self->order->$cv_method->language_id)
596 ->focus( '#order_' . $self->cv . '_id')
597 ->run('kivi.Order.update_exchangerate');
599 $self->js_redisplay_cvpartnumbers;
603 # open the dialog for customer/vendor details
604 sub action_show_customer_vendor_details_dialog {
607 my $is_customer = 'customer' eq $::form->{vc};
610 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
612 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
615 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
616 $details{discount_as_percent} = $cv->discount_as_percent;
617 $details{creditlimt} = $cv->creditlimit_as_number;
618 $details{business} = $cv->business->description if $cv->business;
619 $details{language} = $cv->language_obj->description if $cv->language_obj;
620 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
621 $details{payment_terms} = $cv->payment->description if $cv->payment;
622 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
624 foreach my $entry (@{ $cv->shipto }) {
625 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
627 foreach my $entry (@{ $cv->contacts }) {
628 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
631 $_[0]->render('common/show_vc_details', { layout => 0 },
632 is_customer => $is_customer,
637 # called if a unit in an existing item row is changed
638 sub action_unit_changed {
641 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
642 my $item = $self->order->items_sorted->[$idx];
644 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
645 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
648 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
649 $self->js_redisplay_line_values;
653 # add an item row for a new item entered in the input row
654 sub action_add_item {
657 delete $::form->{add_item}->{create_part_type};
659 my $form_attr = $::form->{add_item};
661 return unless $form_attr->{parts_id};
663 my $item = new_item($self->order, $form_attr);
665 $self->order->add_items($item);
667 $self->get_item_cvpartnumber($item);
669 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
670 my $row_as_html = $self->p->render('delivery_order/tabs/_row',
674 in_out => $self->type_data->transfer,
677 if ($::form->{insert_before_item_id}) {
679 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
682 ->append('#row_table_id', $row_as_html);
685 if ( $item->part->is_assortment ) {
686 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
687 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
688 my $attr = { parts_id => $assortment_item->parts_id,
689 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
690 unit => $assortment_item->unit,
691 description => $assortment_item->part->description,
693 my $item = new_item($self->order, $attr);
695 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
696 $item->discount(1) unless $assortment_item->charge;
698 $self->order->add_items( $item );
699 $self->get_item_cvpartnumber($item);
700 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
701 my $row_as_html = $self->p->render('delivery_order/tabs/_row',
706 if ($::form->{insert_before_item_id}) {
708 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
711 ->append('#row_table_id', $row_as_html);
717 ->val('.add_item_input', '')
718 ->run('kivi.Order.init_row_handlers')
719 ->run('kivi.Order.renumber_positions')
720 ->focus('#add_item_parts_id_name');
722 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
727 # add item rows for multiple items at once
728 sub action_add_multi_items {
731 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
732 return $self->js->render() unless scalar @form_attr;
735 foreach my $attr (@form_attr) {
736 my $item = new_item($self->order, $attr);
738 if ( $item->part->is_assortment ) {
739 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
740 my $attr = { parts_id => $assortment_item->parts_id,
741 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
742 unit => $assortment_item->unit,
743 description => $assortment_item->part->description,
745 my $item = new_item($self->order, $attr);
747 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
748 $item->discount(1) unless $assortment_item->charge;
753 $self->order->add_items(@items);
755 foreach my $item (@items) {
756 $self->get_item_cvpartnumber($item);
757 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
758 my $row_as_html = $self->p->render('delivery_order/tabs/_row',
762 in_out => $self->type_data->transfer,
765 if ($::form->{insert_before_item_id}) {
767 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
770 ->append('#row_table_id', $row_as_html);
775 ->run('kivi.Part.close_picker_dialogs')
776 ->run('kivi.Order.init_row_handlers')
777 ->run('kivi.Order.renumber_positions')
778 ->focus('#add_item_parts_id_name');
780 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
785 sub action_update_exchangerate {
789 is_standard => $self->order->currency_id == $::instance_conf->get_currency_id,
790 currency_name => $self->order->currency->name,
793 $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
796 # redisplay item rows if they are sorted by an attribute
797 sub action_reorder_items {
801 partnumber => sub { $_[0]->part->partnumber },
802 description => sub { $_[0]->description },
803 qty => sub { $_[0]->qty },
804 sellprice => sub { $_[0]->sellprice },
805 discount => sub { $_[0]->discount },
806 cvpartnumber => sub { $_[0]->{cvpartnumber} },
809 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
811 my $method = $sort_keys{$::form->{order_by}};
812 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
813 if ($::form->{sort_dir}) {
814 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
815 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
817 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
820 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
821 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
823 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
827 ->run('kivi.Order.redisplay_items', \@to_sort)
831 # show the popup to choose a price/discount source
832 sub action_price_popup {
835 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
836 my $item = $self->order->items_sorted->[$idx];
838 $self->render_price_dialog($item);
841 # save the order in a session variable and redirect to the part controller
842 sub action_create_part {
845 my $previousform = $::auth->save_form_in_session(non_scalars => 1);
847 my $callback = $self->url_for(
848 action => 'return_from_create_part',
849 type => $self->type, # type is needed for check_auth on return
850 previousform => $previousform,
853 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.'));
855 my @redirect_params = (
856 controller => 'Part',
858 part_type => $::form->{add_item}->{create_part_type},
859 callback => $callback,
863 $self->redirect_to(@redirect_params);
866 sub action_return_from_create_part {
869 $self->{created_part} = SL::DB::Part->new(id => delete $::form->{new_parts_id})->load if $::form->{new_parts_id};
871 $::auth->restore_form_from_session(delete $::form->{previousform});
873 # set item ids to new fake id, to identify them as new items
874 foreach my $item (@{$self->order->items_sorted}) {
875 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
878 $self->get_unalterable_data();
881 # trigger rendering values for second row/longdescription as hidden,
882 # because they are loaded only on demand. So we need to keep the values
884 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
885 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
888 'delivery_order/form',
889 title => $self->get_title_for('edit'),
890 %{$self->{template_args}}
895 sub action_stock_in_out_dialog {
898 my $part = SL::DB::Part->load_cached($::form->{parts_id}) or die "need parts_id";
899 my $stock = $::form->{stock};
900 my $unit = $::form->{unit};
901 my $row = $::form->{row};
902 my $item_id = $::form->{item_id};
903 my $qty = _parse_number($::form->{qty_as_number});
905 my $inout = $self->type_data->transfer;
907 my @contents = DO->get_item_availability(parts_id => $part->id);
908 my $stock_info = DO->unpack_stock_information(packed => $stock);
910 $self->merge_stock_data($stock_info, \@contents, $part);
912 $self->render("delivery_order/stock_dialog", { layout => 0 },
913 WHCONTENTS => $self->order->delivered ? $stock_info : \@contents,
917 delivered => $self->order->delivered,
923 sub action_update_stock_information {
926 my $stock_info = $::form->{stock_info};
927 my $unit = $::form->{unit};
928 my $yaml = SL::YAML::Dump($stock_info);
929 my $stock_qty = $self->calculate_stock_in_out_from_stock_info($unit, $stock_info);
933 stock_qty => $stock_qty,
936 $self->render(\ SL::JSON::to_json($response), { layout => 0, type => 'json', process => 0 });
939 sub merge_stock_data {
940 my ($self, $stock_info, $contents, $part) = @_;
941 # TODO rewrite to mapping
943 if (!$self->order->delivered) {
944 for my $row (@$contents) {
945 $row->{available_qty} = _format_number_units($row->{qty}, $row->{unit}, $part->unit);
947 for my $sinfo (@{ $stock_info }) {
948 next if $row->{bin_id} != $sinfo->{bin_id} ||
949 $row->{warehouse_id} != $sinfo->{warehouse_id} ||
950 $row->{chargenumber} ne $sinfo->{chargenumber} ||
951 $row->{bestbefore} ne $sinfo->{bestbefore};
953 $row->{"stock_$_"} = $sinfo->{$_}
954 for qw(qty unit error delivery_order_items_stock_id);
959 for my $sinfo (@{ $stock_info }) {
960 my $bin = SL::DB::Bin->load_cached($sinfo->{bin_id});
961 $sinfo->{warehouse_description} = $bin->warehouse->description;
962 $sinfo->{bin_description} = $bin->description;
963 map { $sinfo->{"stock_$_"} = $sinfo->{$_} } qw(qty unit);
968 # load the second row for one or more items
970 # This action gets the html code for all items second rows by rendering a template for
971 # the second row and sets the html code via client js.
972 sub action_load_second_rows {
975 foreach my $item_id (@{ $::form->{item_ids} }) {
976 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
977 my $item = $self->order->items_sorted->[$idx];
979 $self->js_load_second_row($item, $item_id, 0);
982 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
987 # update description, notes and sellprice from master data
988 sub action_update_row_from_master_data {
991 foreach my $item_id (@{ $::form->{item_ids} }) {
992 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
993 my $item = $self->order->items_sorted->[$idx];
994 my $texts = get_part_texts($item->part, $self->order->language_id);
996 $item->description($texts->{description});
997 $item->longdescription($texts->{longdescription});
999 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1002 if ($item->part->is_assortment) {
1003 # add assortment items with price 0, as the components carry the price
1004 $price_src = $price_source->price_from_source("");
1005 $price_src->price(0);
1007 $price_src = $price_source->best_price
1008 ? $price_source->best_price
1009 : $price_source->price_from_source("");
1010 $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate;
1011 $price_src->price(0) if !$price_source->best_price;
1015 $item->sellprice($price_src->price);
1016 $item->active_price_source($price_src);
1019 ->run('kivi.Order.update_sellprice', $item_id, $item->sellprice_as_number)
1020 ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
1021 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
1022 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
1024 if ($self->search_cvpartnumber) {
1025 $self->get_item_cvpartnumber($item);
1026 $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
1030 $self->js_redisplay_line_values;
1032 $self->js->render();
1035 sub action_transfer_stock {
1038 if ($self->order->delivered) {
1039 return $self->js->flash("error", t8('The parts for this order have already been transferred'))->render;
1042 my $errors = $self->save;
1045 $self->js->flash('error', $_) for @$errors;
1046 return $self->js->render;
1049 my $order = $self->order;
1051 # TODO move to type data
1052 my $trans_type = $self->type_data->properties('transfer') eq 'in'
1053 ? SL::DB::Manager::TransferType->find_by(direction => "id", description => "stock")
1054 : SL::DB::Manager::TransferType->find_by(direction => "out", description => "shipped");
1056 my @transfer_requests;
1058 for my $item (@{ $order->items_sorted }) {
1059 for my $stock (@{ $item->delivery_order_stock_entries }) {
1060 my $transfer = SL::DB::Inventory->new_from($stock);
1061 $transfer->trans_type($trans_type);
1063 push @transfer_requests, $transfer;
1067 if (!@transfer_requests) {
1068 $self->js->flash("error", t8("No stock to transfer"))->render;
1071 SL::DB->client->with_transaction(sub {
1072 $_->save for @transfer_requests;
1073 $self->order->update_attributes(delivered => 1);
1077 ->flash("info", t8("Stock transfered"))
1078 ->run('kivi.ActionBar.setDisabled', '#transfer_out_action', t8('The parts for this order have already been transferred'))
1079 ->run('kivi.ActionBar.setDisabled', '#transfer_in_action', t8('The parts for this order have already been transferred'))
1084 sub js_load_second_row {
1085 my ($self, $item, $item_id, $do_parse) = @_;
1088 # Parse values from form (they are formated while rendering (template)).
1089 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1090 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1091 foreach my $var (@{ $item->cvars_by_config }) {
1092 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1094 $item->parse_custom_variable_values;
1097 my $row_as_html = $self->p->render('delivery_order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1100 ->html('#second_row_' . $item_id, $row_as_html)
1101 ->data('#second_row_' . $item_id, 'loaded', 1);
1104 sub js_redisplay_line_values {
1107 my $is_sales = $self->order->is_sales;
1109 # sales orders with margins
1114 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1115 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1116 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1117 ]} @{ $self->order->items_sorted };
1121 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1122 ]} @{ $self->order->items_sorted };
1126 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1129 sub js_redisplay_cvpartnumbers {
1132 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1134 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1137 ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
1140 sub js_reset_order_and_item_ids_after_save {
1144 ->val('#id', $self->order->id)
1145 ->val('#converted_from_oe_id', '')
1146 ->val('#order_' . $self->nr_key(), $self->order->number);
1149 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1150 next if !$self->order->items_sorted->[$idx]->id;
1151 next if $form_item_id !~ m{^new};
1153 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1154 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1155 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1159 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1169 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1170 die "Not a valid type for delivery order";
1173 $self->type($::form->{type});
1179 return $self->type_data->customervendor;
1182 sub init_search_cvpartnumber {
1185 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1186 my $search_cvpartnumber;
1187 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1188 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1190 return $search_cvpartnumber;
1193 sub init_show_update_button {
1196 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1207 sub init_all_price_factors {
1208 SL::DB::Manager::PriceFactor->get_all;
1211 sub init_part_picker_classification_ids {
1214 return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => $self->type_data->part_classification_query) } ];
1220 $::auth->assert($self->type_data->access || 'DOES_NOT_EXIST');
1223 # build the selection box for contacts
1225 # Needed, if customer/vendor changed.
1226 sub build_contact_select {
1229 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1230 value_key => 'cp_id',
1231 title_key => 'full_name_dep',
1232 default => $self->order->cp_id,
1234 style => 'width: 300px',
1238 # build the selection box for shiptos
1240 # Needed, if customer/vendor changed.
1241 sub build_shipto_select {
1244 select_tag('order.shipto_id',
1245 [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
1246 value_key => 'shipto_id',
1247 title_key => 'displayable_id',
1248 default => $self->order->shipto_id,
1250 style => 'width: 300px',
1254 # build the inputs for the cusom shipto dialog
1256 # Needed, if customer/vendor changed.
1257 sub build_shipto_inputs {
1260 my $content = $self->p->render('common/_ship_to_dialog',
1261 vc_obj => $self->order->customervendor,
1262 cs_obj => $self->order->custom_shipto,
1263 cvars => $self->order->custom_shipto->cvars_by_config,
1264 id_selector => '#order_shipto_id');
1266 div_tag($content, id => 'shipto_inputs');
1269 # render the info line for business
1271 # Needed, if customer/vendor changed.
1272 sub build_business_info_row
1274 $_[0]->p->render('delivery_order/tabs/_business_info_row', SELF => $_[0]);
1281 return if !$::form->{id};
1283 $self->order(SL::DB::DeliveryOrder->new(id => $::form->{id})->load);
1285 # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
1286 # You need a custom shipto object to call cvars_by_config to get the cvars.
1287 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
1289 $self->prepare_stock_info($_) for $self->order->items;
1291 return $self->order;
1294 # load or create a new order object
1296 # And assign changes from the form to this object.
1297 # If the order is loaded from db, check if items are deleted in the form,
1298 # remove them form the object and collect them for removing from db on saving.
1299 # Then create/update items from form (via make_item) and add them.
1303 # add_items adds items to an order with no items for saving, but they cannot
1304 # be retrieved via items until the order is saved. Adding empty items to new
1305 # order here solves this problem.
1307 $order = SL::DB::DeliveryOrder->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1308 $order ||= SL::DB::DeliveryOrder->new(orderitems => [], currency_id => $::instance_conf->get_currency_id(), order_type => $self->type_data->validate($::form->{type}));
1310 my $cv_id_method = $self->cv . '_id';
1311 if (!$::form->{id} && $::form->{$cv_id_method}) {
1312 $order->$cv_id_method($::form->{$cv_id_method});
1313 setup_order_from_cv($order);
1316 my $form_orderitems = delete $::form->{order}->{orderitems};
1318 $order->assign_attributes(%{$::form->{order}});
1320 $self->setup_custom_shipto_from_form($order, $::form);
1322 # remove deleted items
1323 $self->item_ids_to_delete([]);
1324 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1325 my $item = $order->orderitems->[$idx];
1326 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1327 splice @{$order->orderitems}, $idx, 1;
1328 push @{$self->item_ids_to_delete}, $item->id;
1334 foreach my $form_attr (@{$form_orderitems}) {
1335 my $item = make_item($order, $form_attr);
1336 $item->position($pos);
1341 $self->prepare_stock_info($_) for $order->items, @items;
1343 $order->add_items(grep {!$_->id} @items);
1348 # create or update items from form
1350 # Make item objects from form values. For items already existing read from db.
1351 # Create a new item else. And assign attributes.
1353 my ($record, $attr) = @_;
1356 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1358 my $is_new = !$item;
1360 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1361 # they cannot be retrieved via custom_variables until the order/orderitem is
1362 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1363 $item ||= SL::DB::DeliveryOrderItem->new(custom_variables => []);
1366 if (my $stock_info = delete $attr->{stock_info}) {
1367 my %existing = map { $_->id => $_ } $item->delivery_order_stock_entries;
1370 for my $line (@{ DO->unpack_stock_information(packed => $stock_info) }) {
1371 # lookup existing or make new
1372 my $obj = delete $existing{$line->{delivery_order_items_stock_id}}
1373 // SL::DB::DeliveryOrderItemsStock->new;
1376 $obj->$_($line->{$_}) for qw(bin_id warehouse_id chargenumber qty unit);
1377 $obj->bestbefore_as_date($line->{bestfbefore})
1378 if $line->{bestbefore} && $::instance_conf->get_show_bestbefore;
1382 $item->delivery_order_stock_entries(@save);
1385 $item->assign_attributes(%$attr);
1388 my $texts = get_part_texts($item->part, $record->language_id);
1389 $item->longdescription($texts->{longdescription}) if !defined $attr->{longdescription};
1390 $item->project_id($record->globalproject_id) if !defined $attr->{project_id};
1391 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number};
1399 # This is used to add one item
1401 my ($record, $attr) = @_;
1403 my $item = SL::DB::DeliveryOrderItem->new;
1405 # Remove attributes where the user left or set the inputs empty.
1406 # So these attributes will be undefined and we can distinguish them
1407 # from zero later on.
1408 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1409 delete $attr->{$_} if $attr->{$_} eq '';
1412 $item->assign_attributes(%$attr);
1414 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1415 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1417 $item->unit($part->unit) if !$item->unit;
1420 if ( $part->is_assortment ) {
1421 # add assortment items with price 0, as the components carry the price
1422 $price_src = $price_source->price_from_source("");
1423 $price_src->price(0);
1424 } elsif (defined $item->sellprice) {
1425 $price_src = $price_source->price_from_source("");
1426 $price_src->price($item->sellprice);
1428 $price_src = $price_source->best_price
1429 ? $price_source->best_price
1430 : $price_source->price_from_source("");
1431 $price_src->price(0) if !$price_source->best_price;
1435 if (defined $item->discount) {
1436 $discount_src = $price_source->discount_from_source("");
1437 $discount_src->discount($item->discount);
1439 $discount_src = $price_source->best_discount
1440 ? $price_source->best_discount
1441 : $price_source->discount_from_source("");
1442 $discount_src->discount(0) if !$price_source->best_discount;
1446 $new_attr{part} = $part;
1447 $new_attr{description} = $part->description if ! $item->description;
1448 $new_attr{qty} = 1.0 if ! $item->qty;
1449 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1450 $new_attr{sellprice} = $price_src->price;
1451 $new_attr{discount} = $discount_src->discount;
1452 $new_attr{active_price_source} = $price_src;
1453 $new_attr{active_discount_source} = $discount_src;
1454 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1455 $new_attr{project_id} = $record->globalproject_id;
1456 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1458 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1459 # they cannot be retrieved via custom_variables until the order/orderitem is
1460 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1461 $new_attr{custom_variables} = [];
1463 my $texts = get_part_texts($part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription});
1465 $item->assign_attributes(%new_attr, %{ $texts });
1470 sub prepare_stock_info {
1471 my ($self, $item) = @_;
1473 $item->{stock_info} = SL::YAML::Dump([
1475 delivery_order_items_stock_id => $_->id,
1477 warehouse_id => $_->warehouse_id,
1478 bin_id => $_->bin_id,
1479 chargenumber => $_->chargenumber,
1481 }, $item->delivery_order_stock_entries
1485 sub setup_order_from_cv {
1488 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id));
1490 $order->intnotes($order->customervendor->notes);
1492 if ($order->is_sales) {
1493 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1494 $order->taxincluded(defined($order->customer->taxincluded_checked)
1495 ? $order->customer->taxincluded_checked
1496 : $::myconfig{taxincluded_checked});
1501 # setup custom shipto from form
1503 # The dialog returns form variables starting with 'shipto' and cvars starting
1504 # with 'shiptocvar_'.
1505 # Mark it to be deleted if a shipto from master data is selected
1506 # (i.e. order has a shipto).
1507 # Else, update or create a new custom shipto. If the fields are empty, it
1508 # will not be saved on save.
1509 sub setup_custom_shipto_from_form {
1510 my ($self, $order, $form) = @_;
1512 if ($order->shipto) {
1513 $self->is_custom_shipto_to_delete(1);
1515 my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1517 my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
1518 my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
1520 $custom_shipto->assign_attributes(%$shipto_attrs);
1521 $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
1525 # get data for saving, printing, ..., that is not changed in the form
1527 # Only cvars for now.
1528 sub get_unalterable_data {
1531 foreach my $item (@{ $self->order->items }) {
1532 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1533 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1534 foreach my $var (@{ $item->cvars_by_config }) {
1535 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1537 $item->parse_custom_variable_values;
1543 # And remove related files in the spool directory
1548 my $db = $self->order->db;
1550 $db->with_transaction(
1552 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1553 $self->order->delete;
1554 my $spool = $::lx_office_conf{paths}->{spool};
1555 unlink map { "$spool/$_" } @spoolfiles if $spool;
1557 $self->save_history('DELETED');
1560 }) || push(@{$errors}, $db->error);
1567 # And delete items that are deleted in the form.
1572 my $db = $self->order->db;
1574 $db->with_transaction(sub {
1575 # delete custom shipto if it is to be deleted or if it is empty
1576 if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
1577 $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
1578 $self->order->custom_shipto(undef);
1581 SL::DB::DeliveryOrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1582 $self->order->save(cascade => 1);
1585 if ($::form->{converted_from_oe_id}) {
1586 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1587 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1588 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1589 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/ && $self->order->is_type(PURCHASE_DELIVERY_ORDER_TYPE);
1590 $src->link_to_record($self->order);
1592 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1594 foreach (@{ $self->order->items_sorted }) {
1595 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1597 SL::DB::RecordLink->new(from_table => 'orderitems',
1598 from_id => $from_id,
1599 to_table => 'orderitems',
1607 $self->save_history('SAVED');
1610 }) || push(@{$errors}, $db->error);
1615 sub workflow_sales_or_request_for_quotation {
1619 my $errors = $self->save();
1621 if (scalar @{ $errors }) {
1622 $self->js->flash('error', $_) for @{ $errors };
1623 return $self->js->render();
1626 my $destination_type = $self->type_data->workflow("to_quotation_type");
1628 $self->order(SL::DB::DeliveryOrder->new_from($self->order, destination_type => $destination_type));
1629 $self->{converted_from_oe_id} = delete $::form->{id};
1631 # set item ids to new fake id, to identify them as new items
1632 foreach my $item (@{$self->order->items_sorted}) {
1633 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1637 $::form->{type} = $destination_type;
1638 $self->type($self->init_type);
1639 $self->cv ($self->init_cv);
1642 $self->get_unalterable_data();
1643 $self->pre_render();
1645 # trigger rendering values for second row as hidden, because they
1646 # are loaded only on demand. So we need to keep the values from the
1648 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1651 'delivery_order/form',
1652 title => $self->get_title_for('edit'),
1653 %{$self->{template_args}}
1657 sub workflow_sales_or_purchase_order {
1661 my $errors = $self->save();
1663 if (scalar @{ $errors }) {
1664 $self->js->flash('error', $_) foreach @{ $errors };
1665 return $self->js->render();
1668 my $destination_type = $self->type_data->workflow("to_order_type");
1670 # check for direct delivery
1671 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
1673 if ($self->type_data->workflow("to_order_copy_shipto") && $::form->{use_shipto} && $self->order->shipto) {
1674 $custom_shipto = $self->order->shipto->clone('SL::DB::DeliveryOrder');
1677 $self->order(SL::DB::DeliveryOrder->new_from($self->order, destination_type => $destination_type));
1678 $self->{converted_from_oe_id} = delete $::form->{id};
1680 # set item ids to new fake id, to identify them as new items
1681 foreach my $item (@{$self->order->items_sorted}) {
1682 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1685 if ($self->type_data->workflow("to_order_copy_shipto")) {
1686 if ($::form->{use_shipto}) {
1687 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
1689 # remove any custom shipto if not wanted
1690 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1695 $::form->{type} = $destination_type;
1696 $self->type($self->init_type);
1697 $self->cv ($self->init_cv);
1700 $self->get_unalterable_data();
1701 $self->pre_render();
1703 # trigger rendering values for second row as hidden, because they
1704 # are loaded only on demand. So we need to keep the values from the
1706 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1709 'delivery_order/form',
1710 title => $self->get_title_for('edit'),
1711 %{$self->{template_args}}
1718 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1719 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
1720 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1721 $self->{all_languages} = SL::DB::Manager::Language->get_all_sorted();
1722 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1725 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1728 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1730 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1731 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1732 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1733 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1735 my $print_form = Form->new('');
1736 $print_form->{type} = $self->type;
1737 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1738 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1739 form => $print_form,
1740 options => {dialog_name_prefix => 'print_options.',
1744 no_opendocument => 0,
1748 foreach my $item (@{$self->order->orderitems}) {
1749 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1750 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1751 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1754 if ($self->order->${\ $self->type_data->nr_key } && $::instance_conf->get_webdav) {
1755 my $webdav = SL::Webdav->new(
1756 type => $self->type,
1757 number => $self->order->number,
1759 my @all_objects = $webdav->get_all_objects;
1760 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1762 link => File::Spec->catfile($_->full_filedescriptor),
1766 $self->{template_args}{in_out} = $self->type_data->transfer;
1768 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1770 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.DeliveryOrder kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
1771 calculate_qty kivi.Validator follow_up show_history);
1772 $self->setup_edit_action_bar;
1775 sub setup_edit_action_bar {
1776 my ($self, %params) = @_;
1778 my $deletion_allowed = $self->type_data->show_menu("delete");
1780 for my $bar ($::request->layout->get('actionbar')) {
1785 call => [ 'kivi.DeliveryOrder.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1786 $::instance_conf->get_order_warn_no_deliverydate,
1791 call => [ 'kivi.DeliveryOrder.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1792 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1794 ], # end of combobox "Save"
1801 t8('Save and Quotation'),
1802 submit => [ '#order_form', { action => "DeliveryOrder/sales_quotation" } ],
1803 only_if => $self->type_data->show_menu("save_and_quotation"),
1807 submit => [ '#order_form', { action => "DeliveryOrder/request_for_quotation" } ],
1808 only_if => $self->type_data->show_menu("save_and_rfq"),
1811 t8('Save and Sales Order'),
1812 submit => [ '#order_form', { action => "DeliveryOrder/sales_order" } ],
1813 only_if => $self->type_data->show_menu("save_and_sales_order"),
1816 t8('Save and Purchase Order'),
1817 call => [ 'kivi.DeliveryOrder.purchase_order_check_for_direct_delivery' ],
1818 only_if => $self->type_data->show_menu("save_and_purchase_order"),
1821 t8('Save and Delivery Order'),
1822 call => [ 'kivi.DeliveryOrder.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1823 $::instance_conf->get_order_warn_no_deliverydate,
1825 only_if => $self->type_data->show_menu("save_and_delivery_order"),
1828 t8('Save and Invoice'),
1829 call => [ 'kivi.DeliveryOrder.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1830 only_if => $self->type_data->show_menu("save_and_invoice"),
1833 t8('Save and AP Transaction'),
1834 call => [ 'kivi.DeliveryOrder.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
1835 only_if => $self->type_data->show_menu("save_and_ap_transaction"),
1838 ], # end of combobox "Workflow"
1845 t8('Save and preview PDF'),
1846 call => [ 'kivi.DeliveryOrder.save', 'preview_pdf', $::instance_conf->get_order_warn_duplicate_parts,
1847 $::instance_conf->get_order_warn_no_deliverydate,
1851 t8('Save and print'),
1852 call => [ 'kivi.DeliveryOrder.show_print_options', $::instance_conf->get_order_warn_duplicate_parts,
1853 $::instance_conf->get_order_warn_no_deliverydate,
1857 t8('Save and E-mail'),
1858 id => 'save_and_email_action',
1859 call => [ 'kivi.DeliveryOrder.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts,
1860 $::instance_conf->get_order_warn_no_deliverydate,
1862 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1865 t8('Download attachments of all parts'),
1866 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1867 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1868 only_if => $::instance_conf->get_doc_storage,
1870 ], # end of combobox "Export"
1874 call => [ 'kivi.DeliveryOrder.delete_order' ],
1875 confirm => $::locale->text('Do you really want to delete this object?'),
1876 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1877 only_if => $self->type_data->show_menu("delete"),
1883 id => 'transfer_out_action',
1884 call => [ 'kivi.DeliveryOrder.save', 'transfer_stock' ],
1885 disabled => $self->order->delivered ? t8('The parts for this order have already been transferred') : undef,
1886 only_if => $self->type_data->properties('transfer') eq 'out',
1887 confirm => t8('Do you really want to transfer the stock and set this order to delivered?'),
1891 id => 'transfer_in_action',
1892 call => [ 'kivi.DeliveryOrder.save', 'transfer_stock' ],
1893 disabled => $self->order->delivered ? t8('The parts for this order have already been transferred') : undef,
1894 only_if => $self->type_data->properties('transfer') eq 'in',
1895 confirm => t8('Do you really want to transfer the stock and set this order to delivered?'),
1905 call => [ 'kivi.DeliveryOrder.follow_up_window' ],
1906 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1907 only_if => $::auth->assert('productivity', 1),
1911 call => [ 'set_history_window', $self->order->id, 'id' ],
1912 disabled => !$self->order->id ? t8('This record has not been saved yet.') : undef,
1914 ], # end of combobox "more"
1920 my ($order, $pdf_ref, $params) = @_;
1924 my $print_form = Form->new('');
1925 $print_form->{type} = $order->type;
1926 $print_form->{formname} = $params->{formname} || $order->type;
1927 $print_form->{format} = $params->{format} || 'pdf';
1928 $print_form->{media} = $params->{media} || 'file';
1929 $print_form->{groupitems} = $params->{groupitems};
1930 $print_form->{printer_id} = $params->{printer_id};
1931 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1933 $order->language($params->{language});
1934 $order->flatten_to_form($print_form, format_amounts => 1);
1938 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
1939 $template_ext = 'odt';
1940 $template_type = 'OpenDocument';
1943 # search for the template
1944 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1945 name => $print_form->{formname},
1946 extension => $template_ext,
1947 email => $print_form->{media} eq 'email',
1948 language => $params->{language},
1949 printer_id => $print_form->{printer_id},
1952 if (!defined $template_file) {
1953 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);
1956 return @errors if scalar @errors;
1958 $print_form->throw_on_error(sub {
1960 $print_form->prepare_for_printing;
1962 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1963 format => $print_form->{format},
1964 template_type => $template_type,
1965 template => $template_file,
1966 variables => $print_form,
1967 variable_content_types => {
1968 longdescription => 'html',
1969 partnotes => 'html',
1974 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
1980 sub get_files_for_email_dialog {
1983 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
1985 return %files if !$::instance_conf->get_doc_storage;
1987 if ($self->order->id) {
1988 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
1989 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
1990 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
1991 $files{project_files} = [ SL::File->get_all( object_id => $self->order->globalproject_id, object_type => 'project', file_type => 'attachment') ];
1995 uniq_by { $_->{id} }
1997 +{ id => $_->part->id,
1998 partnumber => $_->part->partnumber }
1999 } @{$self->order->items_sorted};
2001 foreach my $part (@parts) {
2002 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
2003 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
2006 foreach my $key (keys %files) {
2007 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
2014 my ($self, $action) = @_;
2016 return '' if none { lc($action)} qw(add edit);
2017 return $self->type_data->text($action);
2020 sub get_item_cvpartnumber {
2021 my ($self, $item) = @_;
2023 return if !$self->search_cvpartnumber;
2024 return if !$self->order->customervendor;
2026 if ($self->cv eq 'vendor') {
2027 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
2028 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
2029 } elsif ($self->cv eq 'customer') {
2030 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
2031 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
2035 sub get_part_texts {
2036 my ($part_or_id, $language_or_id, %defaults) = @_;
2038 my $part = ref($part_or_id) ? $part_or_id : SL::DB::Part->load_cached($part_or_id);
2039 my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id;
2041 description => $defaults{description} // $part->description,
2042 longdescription => $defaults{longdescription} // $part->notes,
2045 return $texts unless $language_id;
2047 my $translation = SL::DB::Manager::Translation->get_first(
2049 parts_id => $part->id,
2050 language_id => $language_id,
2053 $texts->{description} = $translation->translation if $translation && $translation->translation;
2054 $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription;
2060 return $_[0]->type_data->nr_key;
2063 sub save_and_redirect_to {
2064 my ($self, %params) = @_;
2066 my $errors = $self->save();
2068 if (scalar @{ $errors }) {
2069 $self->js->flash('error', $_) foreach @{ $errors };
2070 return $self->js->render();
2073 flash_later('info', $self->type_data->text("saved"));
2075 $self->redirect_to(%params, id => $self->order->id);
2079 my ($self, $addition) = @_;
2081 my $number_type = $self->nr_key;
2082 my $snumbers = $number_type . '_' . $self->order->$number_type;
2084 SL::DB::History->new(
2085 trans_id => $self->order->id,
2086 employee_id => SL::DB::Manager::Employee->current->id,
2087 what_done => $self->order->type,
2088 snumbers => $snumbers,
2089 addition => $addition,
2093 sub store_pdf_to_webdav_and_filemanagement {
2094 my($order, $content, $filename) = @_;
2098 # copy file to webdav folder
2099 if ($order->number && $::instance_conf->get_webdav_documents) {
2100 my $webdav = SL::Webdav->new(
2101 type => $order->type,
2102 number => $order->number,
2104 my $webdav_file = SL::Webdav::File->new(
2106 filename => $filename,
2109 $webdav_file->store(data => \$content);
2112 push @errors, t8('Storing PDF to webdav folder failed: #1', $@);
2115 if ($order->id && $::instance_conf->get_doc_storage) {
2117 SL::File->save(object_id => $order->id,
2118 object_type => $order->type,
2119 mime_type => 'application/pdf',
2120 source => 'created',
2121 file_type => 'document',
2122 file_name => $filename,
2123 file_contents => $content);
2126 push @errors, t8('Storing PDF in storage backend failed: #1', $@);
2133 sub calculate_stock_in_out_from_stock_info {
2134 my ($self, $unit, $stock_info) = @_;
2136 return "" if !$unit;
2138 my %units_by_name = map { $_->name => $_ } @{ SL::DB::Manager::Unit->get_all };
2140 my $sum = sum0 map {
2141 $units_by_name{$_->{unit}}->convert_to($_->{qty}, $units_by_name{$unit})
2144 my $content = _format_number_units($sum, 2, $units_by_name{$unit}, $units_by_name{$unit});
2149 sub calculate_stock_in_out {
2150 my ($self, $item, $stock_info) = @_;
2152 return "" if !$item->part || !$item->part->unit || !$item->unit;
2154 my $sum = sum0 map {
2155 $_->unit_obj->convert_to($_->qty, $item->unit_obj)
2156 } $item->delivery_order_stock_entries;
2158 my $content = _format_number_units($sum, 2, $item->unit_obj, $item->part->unit_obj);
2163 sub init_type_data {
2164 SL::Controller::DeliveryOrder::TypeData->new($_[0]);
2167 sub init_valid_types {
2168 $_[0]->type_data->valid_types;
2179 SL::Controller::Order - controller for orders
2183 This is a new form to enter orders, completely rewritten with the use
2184 of controller and java script techniques.
2186 The aim is to provide the user a better experience and a faster workflow. Also
2187 the code should be more readable, more reliable and better to maintain.
2195 One input row, so that input happens every time at the same place.
2199 Use of pickers where possible.
2203 Possibility to enter more than one item at once.
2207 Item list in a scrollable area, so that the workflow buttons stay at
2212 Reordering item rows with drag and drop is possible. Sorting item rows is
2213 possible (by partnumber, description, qty, sellprice and discount for now).
2217 No C<update> is necessary. All entries and calculations are managed
2218 with ajax-calls and the page only reloads on C<save>.
2222 User can see changes immediately, because of the use of java script
2233 =item * C<SL/Controller/Order.pm>
2237 =item * C<template/webpages/delivery_order/form.html>
2241 =item * C<template/webpages/delivery_order/tabs/basic_data.html>
2243 Main tab for basic_data.
2245 This is the only tab here for now. "linked records" and "webdav" tabs are
2246 reused from generic code.
2250 =item * C<template/webpages/delivery_order/tabs/_business_info_row.html>
2252 For displaying information on business type
2254 =item * C<template/webpages/delivery_order/tabs/_item_input.html>
2256 The input line for items
2258 =item * C<template/webpages/delivery_order/tabs/_row.html>
2260 One row for already entered items
2262 =item * C<template/webpages/delivery_order/tabs/_tax_row.html>
2264 Displaying tax information
2268 =item * C<js/kivi.DeliveryOrder.js>
2270 java script functions
2280 =item * price sources: little symbols showing better price / better discount
2282 =item * select units in input row?
2284 =item * check for direct delivery (workflow sales order -> purchase order)
2286 =item * access rights
2288 =item * display weights
2292 =item * optional client/user behaviour
2294 (transactions has to be set - department has to be set -
2295 force project if enabled in client config - transport cost reminder)
2299 =head1 KNOWN BUGS AND CAVEATS
2305 Customer discount is not displayed as a valid discount in price source popup
2306 (this might be a bug in price sources)
2308 (I cannot reproduce this (Bernd))
2312 No indication that <shift>-up/down expands/collapses second row.
2316 Inline creation of parts is not currently supported
2320 Table header is not sticky in the scrolling area.
2324 Sorting does not include C<position>, neither does reordering.
2326 This behavior was implemented intentionally. But we can discuss, which behavior
2327 should be implemented.
2331 =head1 To discuss / Nice to have
2337 How to expand/collapse second row. Now it can be done clicking the icon or
2342 Possibility to select PriceSources in input row?
2346 This controller uses a (changed) copy of the template for the PriceSource
2347 dialog. Maybe there could be used one code source.
2351 Rounding-differences between this controller (PriceTaxCalculator) and the old
2352 form. This is not only a problem here, but also in all parts using the PTC.
2353 There exists a ticket and a patch. This patch should be testet.
2357 An indicator, if the actual inputs are saved (like in an
2358 editor or on text processing application).
2362 A warning when leaving the page without saveing unchanged inputs.
2369 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>