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);
1076 $self->js->flash("info", t8("Stock transfered"))->render;
1079 sub js_load_second_row {
1080 my ($self, $item, $item_id, $do_parse) = @_;
1083 # Parse values from form (they are formated while rendering (template)).
1084 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1085 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1086 foreach my $var (@{ $item->cvars_by_config }) {
1087 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1089 $item->parse_custom_variable_values;
1092 my $row_as_html = $self->p->render('delivery_order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1095 ->html('#second_row_' . $item_id, $row_as_html)
1096 ->data('#second_row_' . $item_id, 'loaded', 1);
1099 sub js_redisplay_line_values {
1102 my $is_sales = $self->order->is_sales;
1104 # sales orders with margins
1109 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1110 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1111 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1112 ]} @{ $self->order->items_sorted };
1116 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1117 ]} @{ $self->order->items_sorted };
1121 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1124 sub js_redisplay_cvpartnumbers {
1127 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1129 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1132 ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
1135 sub js_reset_order_and_item_ids_after_save {
1139 ->val('#id', $self->order->id)
1140 ->val('#converted_from_oe_id', '')
1141 ->val('#order_' . $self->nr_key(), $self->order->number);
1144 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1145 next if !$self->order->items_sorted->[$idx]->id;
1146 next if $form_item_id !~ m{^new};
1148 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1149 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1150 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1154 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1164 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1165 die "Not a valid type for delivery order";
1168 $self->type($::form->{type});
1174 return $self->type_data->customervendor;
1177 sub init_search_cvpartnumber {
1180 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1181 my $search_cvpartnumber;
1182 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1183 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1185 return $search_cvpartnumber;
1188 sub init_show_update_button {
1191 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1202 sub init_all_price_factors {
1203 SL::DB::Manager::PriceFactor->get_all;
1206 sub init_part_picker_classification_ids {
1209 return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => $self->type_data->part_classification_query) } ];
1215 $::auth->assert($self->type_data->access || 'DOES_NOT_EXIST');
1218 # build the selection box for contacts
1220 # Needed, if customer/vendor changed.
1221 sub build_contact_select {
1224 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1225 value_key => 'cp_id',
1226 title_key => 'full_name_dep',
1227 default => $self->order->cp_id,
1229 style => 'width: 300px',
1233 # build the selection box for shiptos
1235 # Needed, if customer/vendor changed.
1236 sub build_shipto_select {
1239 select_tag('order.shipto_id',
1240 [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
1241 value_key => 'shipto_id',
1242 title_key => 'displayable_id',
1243 default => $self->order->shipto_id,
1245 style => 'width: 300px',
1249 # build the inputs for the cusom shipto dialog
1251 # Needed, if customer/vendor changed.
1252 sub build_shipto_inputs {
1255 my $content = $self->p->render('common/_ship_to_dialog',
1256 vc_obj => $self->order->customervendor,
1257 cs_obj => $self->order->custom_shipto,
1258 cvars => $self->order->custom_shipto->cvars_by_config,
1259 id_selector => '#order_shipto_id');
1261 div_tag($content, id => 'shipto_inputs');
1264 # render the info line for business
1266 # Needed, if customer/vendor changed.
1267 sub build_business_info_row
1269 $_[0]->p->render('delivery_order/tabs/_business_info_row', SELF => $_[0]);
1276 return if !$::form->{id};
1278 $self->order(SL::DB::DeliveryOrder->new(id => $::form->{id})->load);
1280 # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
1281 # You need a custom shipto object to call cvars_by_config to get the cvars.
1282 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
1284 $self->prepare_stock_info($_) for $self->order->items;
1286 return $self->order;
1289 # load or create a new order object
1291 # And assign changes from the form to this object.
1292 # If the order is loaded from db, check if items are deleted in the form,
1293 # remove them form the object and collect them for removing from db on saving.
1294 # Then create/update items from form (via make_item) and add them.
1298 # add_items adds items to an order with no items for saving, but they cannot
1299 # be retrieved via items until the order is saved. Adding empty items to new
1300 # order here solves this problem.
1302 $order = SL::DB::DeliveryOrder->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1303 $order ||= SL::DB::DeliveryOrder->new(orderitems => [], currency_id => $::instance_conf->get_currency_id(), order_type => $self->type_data->validate($::form->{type}));
1305 my $cv_id_method = $self->cv . '_id';
1306 if (!$::form->{id} && $::form->{$cv_id_method}) {
1307 $order->$cv_id_method($::form->{$cv_id_method});
1308 setup_order_from_cv($order);
1311 my $form_orderitems = delete $::form->{order}->{orderitems};
1313 $order->assign_attributes(%{$::form->{order}});
1315 $self->setup_custom_shipto_from_form($order, $::form);
1317 # remove deleted items
1318 $self->item_ids_to_delete([]);
1319 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1320 my $item = $order->orderitems->[$idx];
1321 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1322 splice @{$order->orderitems}, $idx, 1;
1323 push @{$self->item_ids_to_delete}, $item->id;
1329 foreach my $form_attr (@{$form_orderitems}) {
1330 my $item = make_item($order, $form_attr);
1331 $item->position($pos);
1336 $self->prepare_stock_info($_) for $order->items, @items;
1338 $order->add_items(grep {!$_->id} @items);
1343 # create or update items from form
1345 # Make item objects from form values. For items already existing read from db.
1346 # Create a new item else. And assign attributes.
1348 my ($record, $attr) = @_;
1351 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1353 my $is_new = !$item;
1355 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1356 # they cannot be retrieved via custom_variables until the order/orderitem is
1357 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1358 $item ||= SL::DB::DeliveryOrderItem->new(custom_variables => []);
1361 if (my $stock_info = delete $attr->{stock_info}) {
1362 my %existing = map { $_->id => $_ } $item->delivery_order_stock_entries;
1365 for my $line (@{ DO->unpack_stock_information(packed => $stock_info) }) {
1366 # lookup existing or make new
1367 my $obj = delete $existing{$line->{delivery_order_items_stock_id}}
1368 // SL::DB::DeliveryOrderItemsStock->new;
1371 $obj->$_($line->{$_}) for qw(bin_id warehouse_id chargenumber qty unit);
1372 $obj->bestbefore_as_date($line->{bestfbefore})
1373 if $line->{bestbefore} && $::instance_conf->get_show_bestbefore;
1377 $item->delivery_order_stock_entries(@save);
1380 $item->assign_attributes(%$attr);
1383 my $texts = get_part_texts($item->part, $record->language_id);
1384 $item->longdescription($texts->{longdescription}) if !defined $attr->{longdescription};
1385 $item->project_id($record->globalproject_id) if !defined $attr->{project_id};
1386 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number};
1394 # This is used to add one item
1396 my ($record, $attr) = @_;
1398 my $item = SL::DB::DeliveryOrderItem->new;
1400 # Remove attributes where the user left or set the inputs empty.
1401 # So these attributes will be undefined and we can distinguish them
1402 # from zero later on.
1403 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1404 delete $attr->{$_} if $attr->{$_} eq '';
1407 $item->assign_attributes(%$attr);
1409 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1410 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1412 $item->unit($part->unit) if !$item->unit;
1415 if ( $part->is_assortment ) {
1416 # add assortment items with price 0, as the components carry the price
1417 $price_src = $price_source->price_from_source("");
1418 $price_src->price(0);
1419 } elsif (defined $item->sellprice) {
1420 $price_src = $price_source->price_from_source("");
1421 $price_src->price($item->sellprice);
1423 $price_src = $price_source->best_price
1424 ? $price_source->best_price
1425 : $price_source->price_from_source("");
1426 $price_src->price(0) if !$price_source->best_price;
1430 if (defined $item->discount) {
1431 $discount_src = $price_source->discount_from_source("");
1432 $discount_src->discount($item->discount);
1434 $discount_src = $price_source->best_discount
1435 ? $price_source->best_discount
1436 : $price_source->discount_from_source("");
1437 $discount_src->discount(0) if !$price_source->best_discount;
1441 $new_attr{part} = $part;
1442 $new_attr{description} = $part->description if ! $item->description;
1443 $new_attr{qty} = 1.0 if ! $item->qty;
1444 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1445 $new_attr{sellprice} = $price_src->price;
1446 $new_attr{discount} = $discount_src->discount;
1447 $new_attr{active_price_source} = $price_src;
1448 $new_attr{active_discount_source} = $discount_src;
1449 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1450 $new_attr{project_id} = $record->globalproject_id;
1451 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1453 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1454 # they cannot be retrieved via custom_variables until the order/orderitem is
1455 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1456 $new_attr{custom_variables} = [];
1458 my $texts = get_part_texts($part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription});
1460 $item->assign_attributes(%new_attr, %{ $texts });
1465 sub prepare_stock_info {
1466 my ($self, $item) = @_;
1468 $item->{stock_info} = SL::YAML::Dump([
1470 delivery_order_items_stock_id => $_->id,
1472 warehouse_id => $_->warehouse_id,
1473 bin_id => $_->bin_id,
1474 chargenumber => $_->chargenumber,
1476 }, $item->delivery_order_stock_entries
1480 sub setup_order_from_cv {
1483 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id));
1485 $order->intnotes($order->customervendor->notes);
1487 if ($order->is_sales) {
1488 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1489 $order->taxincluded(defined($order->customer->taxincluded_checked)
1490 ? $order->customer->taxincluded_checked
1491 : $::myconfig{taxincluded_checked});
1496 # setup custom shipto from form
1498 # The dialog returns form variables starting with 'shipto' and cvars starting
1499 # with 'shiptocvar_'.
1500 # Mark it to be deleted if a shipto from master data is selected
1501 # (i.e. order has a shipto).
1502 # Else, update or create a new custom shipto. If the fields are empty, it
1503 # will not be saved on save.
1504 sub setup_custom_shipto_from_form {
1505 my ($self, $order, $form) = @_;
1507 if ($order->shipto) {
1508 $self->is_custom_shipto_to_delete(1);
1510 my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1512 my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
1513 my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
1515 $custom_shipto->assign_attributes(%$shipto_attrs);
1516 $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
1520 # get data for saving, printing, ..., that is not changed in the form
1522 # Only cvars for now.
1523 sub get_unalterable_data {
1526 foreach my $item (@{ $self->order->items }) {
1527 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1528 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1529 foreach my $var (@{ $item->cvars_by_config }) {
1530 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1532 $item->parse_custom_variable_values;
1538 # And remove related files in the spool directory
1543 my $db = $self->order->db;
1545 $db->with_transaction(
1547 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1548 $self->order->delete;
1549 my $spool = $::lx_office_conf{paths}->{spool};
1550 unlink map { "$spool/$_" } @spoolfiles if $spool;
1552 $self->save_history('DELETED');
1555 }) || push(@{$errors}, $db->error);
1562 # And delete items that are deleted in the form.
1567 my $db = $self->order->db;
1569 $db->with_transaction(sub {
1570 # delete custom shipto if it is to be deleted or if it is empty
1571 if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
1572 $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
1573 $self->order->custom_shipto(undef);
1576 SL::DB::DeliveryOrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1577 $self->order->save(cascade => 1);
1580 if ($::form->{converted_from_oe_id}) {
1581 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1582 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1583 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1584 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/ && $self->order->is_type(PURCHASE_DELIVERY_ORDER_TYPE);
1585 $src->link_to_record($self->order);
1587 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1589 foreach (@{ $self->order->items_sorted }) {
1590 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1592 SL::DB::RecordLink->new(from_table => 'orderitems',
1593 from_id => $from_id,
1594 to_table => 'orderitems',
1602 $self->save_history('SAVED');
1605 }) || push(@{$errors}, $db->error);
1610 sub workflow_sales_or_request_for_quotation {
1614 my $errors = $self->save();
1616 if (scalar @{ $errors }) {
1617 $self->js->flash('error', $_) for @{ $errors };
1618 return $self->js->render();
1621 my $destination_type = $self->type_data->workflow("to_quotation_type");
1623 $self->order(SL::DB::DeliveryOrder->new_from($self->order, destination_type => $destination_type));
1624 $self->{converted_from_oe_id} = delete $::form->{id};
1626 # set item ids to new fake id, to identify them as new items
1627 foreach my $item (@{$self->order->items_sorted}) {
1628 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1632 $::form->{type} = $destination_type;
1633 $self->type($self->init_type);
1634 $self->cv ($self->init_cv);
1637 $self->get_unalterable_data();
1638 $self->pre_render();
1640 # trigger rendering values for second row as hidden, because they
1641 # are loaded only on demand. So we need to keep the values from the
1643 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1646 'delivery_order/form',
1647 title => $self->get_title_for('edit'),
1648 %{$self->{template_args}}
1652 sub workflow_sales_or_purchase_order {
1656 my $errors = $self->save();
1658 if (scalar @{ $errors }) {
1659 $self->js->flash('error', $_) foreach @{ $errors };
1660 return $self->js->render();
1663 my $destination_type = $self->type_data->workflow("to_order_type");
1665 # check for direct delivery
1666 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
1668 if ($self->type_data->workflow("to_order_copy_shipto") && $::form->{use_shipto} && $self->order->shipto) {
1669 $custom_shipto = $self->order->shipto->clone('SL::DB::DeliveryOrder');
1672 $self->order(SL::DB::DeliveryOrder->new_from($self->order, destination_type => $destination_type));
1673 $self->{converted_from_oe_id} = delete $::form->{id};
1675 # set item ids to new fake id, to identify them as new items
1676 foreach my $item (@{$self->order->items_sorted}) {
1677 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1680 if ($self->type_data->workflow("to_order_copy_shipto")) {
1681 if ($::form->{use_shipto}) {
1682 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
1684 # remove any custom shipto if not wanted
1685 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1690 $::form->{type} = $destination_type;
1691 $self->type($self->init_type);
1692 $self->cv ($self->init_cv);
1695 $self->get_unalterable_data();
1696 $self->pre_render();
1698 # trigger rendering values for second row as hidden, because they
1699 # are loaded only on demand. So we need to keep the values from the
1701 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1704 'delivery_order/form',
1705 title => $self->get_title_for('edit'),
1706 %{$self->{template_args}}
1713 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1714 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
1715 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1716 $self->{all_languages} = SL::DB::Manager::Language->get_all_sorted();
1717 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1720 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1723 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1725 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1726 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1727 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1728 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1730 my $print_form = Form->new('');
1731 $print_form->{type} = $self->type;
1732 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1733 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1734 form => $print_form,
1735 options => {dialog_name_prefix => 'print_options.',
1739 no_opendocument => 0,
1743 foreach my $item (@{$self->order->orderitems}) {
1744 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1745 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1746 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1749 if ($self->order->${\ $self->type_data->nr_key } && $::instance_conf->get_webdav) {
1750 my $webdav = SL::Webdav->new(
1751 type => $self->type,
1752 number => $self->order->number,
1754 my @all_objects = $webdav->get_all_objects;
1755 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1757 link => File::Spec->catfile($_->full_filedescriptor),
1761 $self->{template_args}{in_out} = $self->type_data->transfer;
1763 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1765 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.DeliveryOrder kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
1766 calculate_qty kivi.Validator follow_up show_history);
1767 $self->setup_edit_action_bar;
1770 sub setup_edit_action_bar {
1771 my ($self, %params) = @_;
1773 my $deletion_allowed = $self->type_data->show_menu("delete");
1775 for my $bar ($::request->layout->get('actionbar')) {
1780 call => [ 'kivi.DeliveryOrder.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1781 $::instance_conf->get_order_warn_no_deliverydate,
1786 call => [ 'kivi.DeliveryOrder.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1787 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1789 ], # end of combobox "Save"
1796 t8('Save and Quotation'),
1797 submit => [ '#order_form', { action => "DeliveryOrder/sales_quotation" } ],
1798 only_if => $self->type_data->show_menu("save_and_quotation"),
1802 submit => [ '#order_form', { action => "DeliveryOrder/request_for_quotation" } ],
1803 only_if => $self->type_data->show_menu("save_and_rfq"),
1806 t8('Save and Sales Order'),
1807 submit => [ '#order_form', { action => "DeliveryOrder/sales_order" } ],
1808 only_if => $self->type_data->show_menu("save_and_sales_order"),
1811 t8('Save and Purchase Order'),
1812 call => [ 'kivi.DeliveryOrder.purchase_order_check_for_direct_delivery' ],
1813 only_if => $self->type_data->show_menu("save_and_purchase_order"),
1816 t8('Save and Delivery Order'),
1817 call => [ 'kivi.DeliveryOrder.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1818 $::instance_conf->get_order_warn_no_deliverydate,
1820 only_if => $self->type_data->show_menu("save_and_delivery_order"),
1823 t8('Save and Invoice'),
1824 call => [ 'kivi.DeliveryOrder.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1825 only_if => $self->type_data->show_menu("save_and_invoice"),
1828 t8('Save and AP Transaction'),
1829 call => [ 'kivi.DeliveryOrder.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
1830 only_if => $self->type_data->show_menu("save_and_ap_transaction"),
1833 ], # end of combobox "Workflow"
1840 t8('Save and preview PDF'),
1841 call => [ 'kivi.DeliveryOrder.save', 'preview_pdf', $::instance_conf->get_order_warn_duplicate_parts,
1842 $::instance_conf->get_order_warn_no_deliverydate,
1846 t8('Save and print'),
1847 call => [ 'kivi.DeliveryOrder.show_print_options', $::instance_conf->get_order_warn_duplicate_parts,
1848 $::instance_conf->get_order_warn_no_deliverydate,
1852 t8('Save and E-mail'),
1853 id => 'save_and_email_action',
1854 call => [ 'kivi.DeliveryOrder.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts,
1855 $::instance_conf->get_order_warn_no_deliverydate,
1857 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1860 t8('Download attachments of all parts'),
1861 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1862 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1863 only_if => $::instance_conf->get_doc_storage,
1865 ], # end of combobox "Export"
1869 call => [ 'kivi.DeliveryOrder.delete_order' ],
1870 confirm => $::locale->text('Do you really want to delete this object?'),
1871 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1872 only_if => $self->type_data->show_menu("delete"),
1878 call => [ 'kivi.DeliveryOrder.save', 'transfer_stock' ],
1879 disabled => $self->order->delivered ? t8('The parts for this order have already been transferred') : undef,
1880 only_if => $self->type_data->properties('transfer') eq 'out',
1881 confirm => t8('Do you really want to transfer the stock and set this order to delivered?'),
1885 call => [ 'kivi.DeliveryOrder.save', 'transfer_stock' ],
1886 disabled => $self->order->delivered ? t8('The parts for this order have already been transferred') : undef,
1887 only_if => $self->type_data->properties('transfer') eq 'in',
1888 confirm => t8('Do you really want to transfer the stock and set this order to delivered?'),
1898 call => [ 'kivi.DeliveryOrder.follow_up_window' ],
1899 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1900 only_if => $::auth->assert('productivity', 1),
1904 call => [ 'set_history_window', $self->order->id, 'id' ],
1905 disabled => !$self->order->id ? t8('This record has not been saved yet.') : undef,
1907 ], # end of combobox "more"
1913 my ($order, $pdf_ref, $params) = @_;
1917 my $print_form = Form->new('');
1918 $print_form->{type} = $order->type;
1919 $print_form->{formname} = $params->{formname} || $order->type;
1920 $print_form->{format} = $params->{format} || 'pdf';
1921 $print_form->{media} = $params->{media} || 'file';
1922 $print_form->{groupitems} = $params->{groupitems};
1923 $print_form->{printer_id} = $params->{printer_id};
1924 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1926 $order->language($params->{language});
1927 $order->flatten_to_form($print_form, format_amounts => 1);
1931 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
1932 $template_ext = 'odt';
1933 $template_type = 'OpenDocument';
1936 # search for the template
1937 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1938 name => $print_form->{formname},
1939 extension => $template_ext,
1940 email => $print_form->{media} eq 'email',
1941 language => $params->{language},
1942 printer_id => $print_form->{printer_id},
1945 if (!defined $template_file) {
1946 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);
1949 return @errors if scalar @errors;
1951 $print_form->throw_on_error(sub {
1953 $print_form->prepare_for_printing;
1955 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1956 format => $print_form->{format},
1957 template_type => $template_type,
1958 template => $template_file,
1959 variables => $print_form,
1960 variable_content_types => {
1961 longdescription => 'html',
1962 partnotes => 'html',
1967 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
1973 sub get_files_for_email_dialog {
1976 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
1978 return %files if !$::instance_conf->get_doc_storage;
1980 if ($self->order->id) {
1981 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
1982 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
1983 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
1984 $files{project_files} = [ SL::File->get_all( object_id => $self->order->globalproject_id, object_type => 'project', file_type => 'attachment') ];
1988 uniq_by { $_->{id} }
1990 +{ id => $_->part->id,
1991 partnumber => $_->part->partnumber }
1992 } @{$self->order->items_sorted};
1994 foreach my $part (@parts) {
1995 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
1996 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
1999 foreach my $key (keys %files) {
2000 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
2007 my ($self, $action) = @_;
2009 return '' if none { lc($action)} qw(add edit);
2010 return $self->type_data->text($action);
2013 sub get_item_cvpartnumber {
2014 my ($self, $item) = @_;
2016 return if !$self->search_cvpartnumber;
2017 return if !$self->order->customervendor;
2019 if ($self->cv eq 'vendor') {
2020 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
2021 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
2022 } elsif ($self->cv eq 'customer') {
2023 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
2024 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
2028 sub get_part_texts {
2029 my ($part_or_id, $language_or_id, %defaults) = @_;
2031 my $part = ref($part_or_id) ? $part_or_id : SL::DB::Part->load_cached($part_or_id);
2032 my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id;
2034 description => $defaults{description} // $part->description,
2035 longdescription => $defaults{longdescription} // $part->notes,
2038 return $texts unless $language_id;
2040 my $translation = SL::DB::Manager::Translation->get_first(
2042 parts_id => $part->id,
2043 language_id => $language_id,
2046 $texts->{description} = $translation->translation if $translation && $translation->translation;
2047 $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription;
2053 return $_[0]->type_data->nr_key;
2056 sub save_and_redirect_to {
2057 my ($self, %params) = @_;
2059 my $errors = $self->save();
2061 if (scalar @{ $errors }) {
2062 $self->js->flash('error', $_) foreach @{ $errors };
2063 return $self->js->render();
2066 flash_later('info', $self->type_data->text("saved"));
2068 $self->redirect_to(%params, id => $self->order->id);
2072 my ($self, $addition) = @_;
2074 my $number_type = $self->nr_key;
2075 my $snumbers = $number_type . '_' . $self->order->$number_type;
2077 SL::DB::History->new(
2078 trans_id => $self->order->id,
2079 employee_id => SL::DB::Manager::Employee->current->id,
2080 what_done => $self->order->type,
2081 snumbers => $snumbers,
2082 addition => $addition,
2086 sub store_pdf_to_webdav_and_filemanagement {
2087 my($order, $content, $filename) = @_;
2091 # copy file to webdav folder
2092 if ($order->number && $::instance_conf->get_webdav_documents) {
2093 my $webdav = SL::Webdav->new(
2094 type => $order->type,
2095 number => $order->number,
2097 my $webdav_file = SL::Webdav::File->new(
2099 filename => $filename,
2102 $webdav_file->store(data => \$content);
2105 push @errors, t8('Storing PDF to webdav folder failed: #1', $@);
2108 if ($order->id && $::instance_conf->get_doc_storage) {
2110 SL::File->save(object_id => $order->id,
2111 object_type => $order->type,
2112 mime_type => 'application/pdf',
2113 source => 'created',
2114 file_type => 'document',
2115 file_name => $filename,
2116 file_contents => $content);
2119 push @errors, t8('Storing PDF in storage backend failed: #1', $@);
2126 sub calculate_stock_in_out_from_stock_info {
2127 my ($self, $unit, $stock_info) = @_;
2129 return "" if !$unit;
2131 my %units_by_name = map { $_->name => $_ } @{ SL::DB::Manager::Unit->get_all };
2133 my $sum = sum0 map {
2134 $units_by_name{$_->{unit}}->convert_to($_->{qty}, $units_by_name{$unit})
2137 my $content = _format_number_units($sum, 2, $units_by_name{$unit}, $units_by_name{$unit});
2142 sub calculate_stock_in_out {
2143 my ($self, $item, $stock_info) = @_;
2145 return "" if !$item->part || !$item->part->unit || !$item->unit;
2147 my $sum = sum0 map {
2148 $_->unit_obj->convert_to($_->qty, $item->unit_obj)
2149 } $item->delivery_order_stock_entries;
2151 my $content = _format_number_units($sum, 2, $item->unit_obj, $item->part->unit_obj);
2156 sub init_type_data {
2157 SL::Controller::DeliveryOrder::TypeData->new($_[0]);
2160 sub init_valid_types {
2161 $_[0]->type_data->valid_types;
2172 SL::Controller::Order - controller for orders
2176 This is a new form to enter orders, completely rewritten with the use
2177 of controller and java script techniques.
2179 The aim is to provide the user a better experience and a faster workflow. Also
2180 the code should be more readable, more reliable and better to maintain.
2188 One input row, so that input happens every time at the same place.
2192 Use of pickers where possible.
2196 Possibility to enter more than one item at once.
2200 Item list in a scrollable area, so that the workflow buttons stay at
2205 Reordering item rows with drag and drop is possible. Sorting item rows is
2206 possible (by partnumber, description, qty, sellprice and discount for now).
2210 No C<update> is necessary. All entries and calculations are managed
2211 with ajax-calls and the page only reloads on C<save>.
2215 User can see changes immediately, because of the use of java script
2226 =item * C<SL/Controller/Order.pm>
2230 =item * C<template/webpages/delivery_order/form.html>
2234 =item * C<template/webpages/delivery_order/tabs/basic_data.html>
2236 Main tab for basic_data.
2238 This is the only tab here for now. "linked records" and "webdav" tabs are
2239 reused from generic code.
2243 =item * C<template/webpages/delivery_order/tabs/_business_info_row.html>
2245 For displaying information on business type
2247 =item * C<template/webpages/delivery_order/tabs/_item_input.html>
2249 The input line for items
2251 =item * C<template/webpages/delivery_order/tabs/_row.html>
2253 One row for already entered items
2255 =item * C<template/webpages/delivery_order/tabs/_tax_row.html>
2257 Displaying tax information
2261 =item * C<js/kivi.DeliveryOrder.js>
2263 java script functions
2273 =item * price sources: little symbols showing better price / better discount
2275 =item * select units in input row?
2277 =item * check for direct delivery (workflow sales order -> purchase order)
2279 =item * access rights
2281 =item * display weights
2285 =item * optional client/user behaviour
2287 (transactions has to be set - department has to be set -
2288 force project if enabled in client config - transport cost reminder)
2292 =head1 KNOWN BUGS AND CAVEATS
2298 Customer discount is not displayed as a valid discount in price source popup
2299 (this might be a bug in price sources)
2301 (I cannot reproduce this (Bernd))
2305 No indication that <shift>-up/down expands/collapses second row.
2309 Inline creation of parts is not currently supported
2313 Table header is not sticky in the scrolling area.
2317 Sorting does not include C<position>, neither does reordering.
2319 This behavior was implemented intentionally. But we can discuss, which behavior
2320 should be implemented.
2324 =head1 To discuss / Nice to have
2330 How to expand/collapse second row. Now it can be done clicking the icon or
2335 Possibility to select PriceSources in input row?
2339 This controller uses a (changed) copy of the template for the PriceSource
2340 dialog. Maybe there could be used one code source.
2344 Rounding-differences between this controller (PriceTaxCalculator) and the old
2345 form. This is not only a problem here, but also in all parts using the PTC.
2346 There exists a ticket and a patch. This patch should be testet.
2350 An indicator, if the actual inputs are saved (like in an
2351 editor or on text processing application).
2355 A warning when leaving the page without saveing unchanged inputs.
2362 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>