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);
22 use SL::DB::PartClassification;
23 use SL::DB::PartsGroup;
26 use SL::DB::RecordLink;
28 use SL::DB::Translation;
29 use SL::DB::TransferType;
31 use SL::Helper::CreatePDF qw(:all);
32 use SL::Helper::PrintOptions;
33 use SL::Helper::ShippedQty;
34 use SL::Helper::UserPreferences::PositionsScrollbar;
35 use SL::Helper::UserPreferences::UpdatePositions;
37 use SL::Controller::Helper::GetModels;
38 use SL::Controller::DeliveryOrder::TypeData;
40 use List::Util qw(first sum0);
41 use List::UtilsBy qw(sort_by uniq_by);
42 use List::MoreUtils qw(any none pairwise first_index);
43 use English qw(-no_match_vars);
48 use Rose::Object::MakeMethods::Generic
50 scalar => [ qw(item_ids_to_delete is_custom_shipto_to_delete) ],
51 'scalar --get_set_init' => [ qw(order valid_types type cv p all_price_factors search_cvpartnumber show_update_button part_picker_classification_ids type_data) ],
56 __PACKAGE__->run_before('check_auth');
58 __PACKAGE__->run_before('get_unalterable_data',
59 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
70 $self->order->transdate(DateTime->now_local());
71 $self->type_data->set_reqdate_by_type;
76 'delivery_order/form',
77 title => $self->get_title_for('add'),
78 %{$self->{template_args}}
82 sub action_add_from_order {
84 # this interfers with init_order
85 $self->{converted_from_oe_id} = delete $::form->{id};
87 # TODO copy data and remember to link them on save
92 # edit an existing order
100 # this is to edit an order from an unsaved order object
102 # set item ids to new fake id, to identify them as new items
103 foreach my $item (@{$self->order->items_sorted}) {
104 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
106 # trigger rendering values for second row as hidden, because they
107 # are loaded only on demand. So we need to keep the values from
109 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
114 'delivery_order/form',
115 title => $self->get_title_for('edit'),
116 %{$self->{template_args}}
120 # edit a collective order (consisting of one or more existing orders)
121 sub action_edit_collective {
125 my @multi_ids = map {
126 $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1}
127 } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form;
129 # fall back to add if no ids are given
130 if (scalar @multi_ids == 0) {
135 # fall back to save as new if only one id is given
136 if (scalar @multi_ids == 1) {
137 $self->order(SL::DB::DeliveryOrder->new(id => $multi_ids[0])->load);
138 $self->action_save_as_new();
142 # make new order from given orders
143 my @multi_orders = map { SL::DB::DeliveryOrder->new(id => $_)->load } @multi_ids;
144 $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders;
145 $self->order(SL::DB::DeliveryOrder->new_from_multi(\@multi_orders, sort_sources_by => 'transdate'));
147 $self->action_edit();
154 my $errors = $self->delete();
156 if (scalar @{ $errors }) {
157 $self->js->flash('error', $_) foreach @{ $errors };
158 return $self->js->render();
161 flash_later('info', $self->type_data->text("delete"));
163 my @redirect_params = (
168 $self->redirect_to(@redirect_params);
175 my $errors = $self->save();
177 if (scalar @{ $errors }) {
178 $self->js->flash('error', $_) foreach @{ $errors };
179 return $self->js->render();
182 flash_later('info', $self->type_data->text("saved"));
184 my @redirect_params = (
187 id => $self->order->id,
190 $self->redirect_to(@redirect_params);
193 # save the order as new document an open it for edit
194 sub action_save_as_new {
197 my $order = $self->order;
200 $self->js->flash('error', t8('This object has not been saved yet.'));
201 return $self->js->render();
204 # load order from db to check if values changed
205 my $saved_order = SL::DB::DeliveryOrder->new(id => $order->id)->load;
208 # Lets assign a new number if the user hasn't changed the previous one.
209 # If it has been changed manually then use it as-is.
210 $new_attrs{number} = (trim($order->number) eq $saved_order->number)
212 : trim($order->number);
214 # Clear transdate unless changed
215 $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
216 ? DateTime->today_local
219 # Set new reqdate unless changed if it is enabled in client config
220 $new_attrs{reqdate} = $self->type_data->get_reqdate_by_type($order->reqdate, $saved_order->reqdate);
223 $new_attrs{employee} = SL::DB::Manager::Employee->current;
225 # Create new record from current one
226 $self->order(SL::DB::DeliveryOrder->new_from($order, destination_type => $order->type, attributes => \%new_attrs));
228 # no linked records on save as new
229 delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
232 $self->action_save();
237 # This is called if "print" is pressed in the print dialog.
238 # If PDF creation was requested and succeeded, the pdf is offered for download
239 # via send_file (which uses ajax in this case).
243 my $errors = $self->save();
245 if (scalar @{ $errors }) {
246 $self->js->flash('error', $_) foreach @{ $errors };
247 return $self->js->render();
250 $self->js_reset_order_and_item_ids_after_save;
252 my $format = $::form->{print_options}->{format};
253 my $media = $::form->{print_options}->{media};
254 my $formname = $::form->{print_options}->{formname};
255 my $copies = $::form->{print_options}->{copies};
256 my $groupitems = $::form->{print_options}->{groupitems};
257 my $printer_id = $::form->{print_options}->{printer_id};
259 # only pdf and opendocument by now
260 if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf)) {
261 return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
264 # only screen or printer by now
265 if (none { $media eq $_ } qw(screen printer)) {
266 return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
269 # create a form for generate_attachment_filename
270 my $form = Form->new;
271 $form->{$self->nr_key()} = $self->order->number;
272 $form->{type} = $self->type;
273 $form->{format} = $format;
274 $form->{formname} = $formname;
275 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
276 my $pdf_filename = $form->generate_attachment_filename();
279 my @errors = generate_pdf($self->order, \$pdf, { format => $format,
280 formname => $formname,
281 language => $self->order->language,
282 printer_id => $printer_id,
283 groupitems => $groupitems });
284 if (scalar @errors) {
285 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
288 if ($media eq 'screen') {
290 $self->js->flash('info', t8('The PDF has been created'));
293 type => SL::MIME->mime_type_from_ext($pdf_filename),
294 name => $pdf_filename,
298 } elsif ($media eq 'printer') {
300 my $printer_id = $::form->{print_options}->{printer_id};
301 SL::DB::Printer->new(id => $printer_id)->load->print_document(
306 $self->js->flash('info', t8('The PDF has been printed'));
309 my @warnings = store_pdf_to_webdav_and_filemanagement($self->order, $pdf, $pdf_filename);
310 if (scalar @warnings) {
311 $self->js->flash('warning', $_) for @warnings;
314 $self->save_history('PRINTED');
317 ->run('kivi.ActionBar.setEnabled', '#save_and_email_action')
320 sub action_preview_pdf {
323 my $errors = $self->save();
324 if (scalar @{ $errors }) {
325 $self->js->flash('error', $_) foreach @{ $errors };
326 return $self->js->render();
329 $self->js_reset_order_and_item_ids_after_save;
332 my $media = 'screen';
333 my $formname = $self->type;
336 # create a form for generate_attachment_filename
337 my $form = Form->new;
338 $form->{$self->nr_key()} = $self->order->number;
339 $form->{type} = $self->type;
340 $form->{format} = $format;
341 $form->{formname} = $formname;
342 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
343 my $pdf_filename = $form->generate_attachment_filename();
346 my @errors = generate_pdf($self->order, \$pdf, { format => $format,
347 formname => $formname,
348 language => $self->order->language,
350 if (scalar @errors) {
351 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
353 $self->save_history('PREVIEWED');
354 $self->js->flash('info', t8('The PDF has been previewed'));
358 type => SL::MIME->mime_type_from_ext($pdf_filename),
359 name => $pdf_filename,
364 # open the email dialog
365 sub action_save_and_show_email_dialog {
368 my $errors = $self->save();
370 if (scalar @{ $errors }) {
371 $self->js->flash('error', $_) foreach @{ $errors };
372 return $self->js->render();
375 my $cv_method = $self->cv;
377 if (!$self->order->$cv_method) {
378 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'))
383 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
384 $email_form->{to} ||= $self->order->$cv_method->email;
385 $email_form->{cc} = $self->order->$cv_method->cc;
386 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
387 # Todo: get addresses from shipto, if any
389 my $form = Form->new;
390 $form->{$self->nr_key()} = $self->order->number;
391 $form->{cusordnumber} = $self->order->cusordnumber;
392 $form->{formname} = $self->type;
393 $form->{type} = $self->type;
394 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
395 $form->{language_id} = $self->order->language->id if $self->order->language;
396 $form->{format} = 'pdf';
397 $form->{cp_id} = $self->order->contact->cp_id if $self->order->contact;
399 $email_form->{subject} = $form->generate_email_subject();
400 $email_form->{attachment_filename} = $form->generate_attachment_filename();
401 $email_form->{message} = $form->generate_email_body();
402 $email_form->{js_send_function} = 'kivi.Order.send_email()';
404 my %files = $self->get_files_for_email_dialog();
405 $self->{all_employees} = SL::DB::Manager::Employee->get_all(query => [ deleted => 0 ]);
406 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
407 email_form => $email_form,
408 show_bcc => $::auth->assert('email_bcc', 'may fail'),
410 is_customer => $self->type_data->is_customer,
411 ALL_EMPLOYEES => $self->{all_employees},
415 ->run('kivi.Order.show_email_dialog', $dialog_html)
422 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
423 sub action_send_email {
426 my $errors = $self->save();
428 if (scalar @{ $errors }) {
429 $self->js->run('kivi.Order.close_email_dialog');
430 $self->js->flash('error', $_) foreach @{ $errors };
431 return $self->js->render();
434 $self->js_reset_order_and_item_ids_after_save;
436 my $email_form = delete $::form->{email_form};
437 my %field_names = (to => 'email');
439 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
441 # for Form::cleanup which may be called in Form::send_email
442 $::form->{cwd} = getcwd();
443 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
445 $::form->{$_} = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
446 $::form->{media} = 'email';
448 if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) {
450 my @errors = generate_pdf($self->order, \$pdf, {media => $::form->{media},
451 format => $::form->{print_options}->{format},
452 formname => $::form->{print_options}->{formname},
453 language => $self->order->language,
454 printer_id => $::form->{print_options}->{printer_id},
455 groupitems => $::form->{print_options}->{groupitems}});
456 if (scalar @errors) {
457 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
460 my @warnings = store_pdf_to_webdav_and_filemanagement($self->order, $pdf, $::form->{attachment_filename});
461 if (scalar @warnings) {
462 flash_later('warning', $_) for @warnings;
465 my $sfile = SL::SessionFile::Random->new(mode => "w");
466 $sfile->fh->print($pdf);
469 $::form->{tmpfile} = $sfile->file_name;
470 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
473 $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a linked record to the mail
474 $::form->send_email(\%::myconfig, 'pdf');
477 my $intnotes = $self->order->intnotes;
478 $intnotes .= "\n\n" if $self->order->intnotes;
479 $intnotes .= t8('[email]') . "\n";
480 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
481 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
482 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
483 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
484 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
485 $intnotes .= t8('Message') . ": " . $::form->{message};
487 $self->order->update_attributes(intnotes => $intnotes);
489 $self->save_history('MAILED');
491 flash_later('info', t8('The email has been sent.'));
493 my @redirect_params = (
496 id => $self->order->id,
499 $self->redirect_to(@redirect_params);
502 # save the order and redirect to the frontend subroutine for a new
504 sub action_save_and_delivery_order {
507 $self->save_and_redirect_to(
508 controller => 'oe.pl',
509 action => 'oe_delivery_order_from_order',
513 # save the order and redirect to the frontend subroutine for a new
515 sub action_save_and_invoice {
518 $self->save_and_redirect_to(
519 controller => 'oe.pl',
520 action => 'oe_invoice_from_order',
524 # workflow from sales order to sales quotation
525 sub action_sales_quotation {
526 $_[0]->workflow_sales_or_request_for_quotation();
529 # workflow from sales order to sales quotation
530 sub action_request_for_quotation {
531 $_[0]->workflow_sales_or_request_for_quotation();
534 # workflow from sales quotation to sales order
535 sub action_sales_order {
536 $_[0]->workflow_sales_or_purchase_order();
539 # workflow from rfq to purchase order
540 sub action_purchase_order {
541 $_[0]->workflow_sales_or_purchase_order();
544 # workflow from purchase order to ap transaction
545 sub action_save_and_ap_transaction {
548 $self->save_and_redirect_to(
549 controller => 'ap.pl',
550 action => 'add_from_purchase_order',
554 # set form elements in respect to a changed customer or vendor
556 # This action is called on an change of the customer/vendor picker.
557 sub action_customer_vendor_changed {
560 setup_order_from_cv($self->order);
562 my $cv_method = $self->cv;
564 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
565 $self->js->show('#cp_row');
567 $self->js->hide('#cp_row');
570 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
571 $self->js->show('#shipto_selection');
573 $self->js->hide('#shipto_selection');
576 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
579 ->replaceWith('#order_cp_id', $self->build_contact_select)
580 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
581 ->replaceWith('#shipto_inputs ', $self->build_shipto_inputs)
582 ->replaceWith('#business_info_row', $self->build_business_info_row)
583 ->val( '#order_taxzone_id', $self->order->taxzone_id)
584 ->val( '#order_taxincluded', $self->order->taxincluded)
585 ->val( '#order_currency_id', $self->order->currency_id)
586 ->val( '#order_payment_id', $self->order->payment_id)
587 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
588 ->val( '#order_intnotes', $self->order->intnotes)
589 ->val( '#order_language_id', $self->order->$cv_method->language_id)
590 ->focus( '#order_' . $self->cv . '_id')
591 ->run('kivi.Order.update_exchangerate');
593 $self->js_redisplay_cvpartnumbers;
597 # open the dialog for customer/vendor details
598 sub action_show_customer_vendor_details_dialog {
601 my $is_customer = 'customer' eq $::form->{vc};
604 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
606 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
609 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
610 $details{discount_as_percent} = $cv->discount_as_percent;
611 $details{creditlimt} = $cv->creditlimit_as_number;
612 $details{business} = $cv->business->description if $cv->business;
613 $details{language} = $cv->language_obj->description if $cv->language_obj;
614 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
615 $details{payment_terms} = $cv->payment->description if $cv->payment;
616 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
618 foreach my $entry (@{ $cv->shipto }) {
619 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
621 foreach my $entry (@{ $cv->contacts }) {
622 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
625 $_[0]->render('common/show_vc_details', { layout => 0 },
626 is_customer => $is_customer,
631 # called if a unit in an existing item row is changed
632 sub action_unit_changed {
635 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
636 my $item = $self->order->items_sorted->[$idx];
638 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
639 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
642 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
643 $self->js_redisplay_line_values;
647 # add an item row for a new item entered in the input row
648 sub action_add_item {
651 delete $::form->{add_item}->{create_part_type};
653 my $form_attr = $::form->{add_item};
655 return unless $form_attr->{parts_id};
657 my $item = new_item($self->order, $form_attr);
659 $self->order->add_items($item);
661 $self->get_item_cvpartnumber($item);
663 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
664 my $row_as_html = $self->p->render('delivery_order/tabs/_row',
668 in_out => $self->type_data->transfer,
671 if ($::form->{insert_before_item_id}) {
673 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
676 ->append('#row_table_id', $row_as_html);
679 if ( $item->part->is_assortment ) {
680 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
681 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
682 my $attr = { parts_id => $assortment_item->parts_id,
683 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
684 unit => $assortment_item->unit,
685 description => $assortment_item->part->description,
687 my $item = new_item($self->order, $attr);
689 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
690 $item->discount(1) unless $assortment_item->charge;
692 $self->order->add_items( $item );
693 $self->get_item_cvpartnumber($item);
694 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
695 my $row_as_html = $self->p->render('delivery_order/tabs/_row',
700 if ($::form->{insert_before_item_id}) {
702 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
705 ->append('#row_table_id', $row_as_html);
711 ->val('.add_item_input', '')
712 ->run('kivi.Order.init_row_handlers')
713 ->run('kivi.Order.renumber_positions')
714 ->focus('#add_item_parts_id_name');
716 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
721 # add item rows for multiple items at once
722 sub action_add_multi_items {
725 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
726 return $self->js->render() unless scalar @form_attr;
729 foreach my $attr (@form_attr) {
730 my $item = new_item($self->order, $attr);
732 if ( $item->part->is_assortment ) {
733 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
734 my $attr = { parts_id => $assortment_item->parts_id,
735 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
736 unit => $assortment_item->unit,
737 description => $assortment_item->part->description,
739 my $item = new_item($self->order, $attr);
741 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
742 $item->discount(1) unless $assortment_item->charge;
747 $self->order->add_items(@items);
749 foreach my $item (@items) {
750 $self->get_item_cvpartnumber($item);
751 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
752 my $row_as_html = $self->p->render('delivery_order/tabs/_row',
756 in_out => $self->type_data->transfer,
759 if ($::form->{insert_before_item_id}) {
761 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
764 ->append('#row_table_id', $row_as_html);
769 ->run('kivi.Part.close_picker_dialogs')
770 ->run('kivi.Order.init_row_handlers')
771 ->run('kivi.Order.renumber_positions')
772 ->focus('#add_item_parts_id_name');
774 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
779 sub action_update_exchangerate {
783 is_standard => $self->order->currency_id == $::instance_conf->get_currency_id,
784 currency_name => $self->order->currency->name,
787 $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
790 # redisplay item rows if they are sorted by an attribute
791 sub action_reorder_items {
795 partnumber => sub { $_[0]->part->partnumber },
796 description => sub { $_[0]->description },
797 qty => sub { $_[0]->qty },
798 sellprice => sub { $_[0]->sellprice },
799 discount => sub { $_[0]->discount },
800 cvpartnumber => sub { $_[0]->{cvpartnumber} },
803 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
805 my $method = $sort_keys{$::form->{order_by}};
806 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
807 if ($::form->{sort_dir}) {
808 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
809 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
811 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
814 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
815 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
817 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
821 ->run('kivi.Order.redisplay_items', \@to_sort)
825 # show the popup to choose a price/discount source
826 sub action_price_popup {
829 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
830 my $item = $self->order->items_sorted->[$idx];
832 $self->render_price_dialog($item);
835 # save the order in a session variable and redirect to the part controller
836 sub action_create_part {
839 my $previousform = $::auth->save_form_in_session(non_scalars => 1);
841 my $callback = $self->url_for(
842 action => 'return_from_create_part',
843 type => $self->type, # type is needed for check_auth on return
844 previousform => $previousform,
847 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.'));
849 my @redirect_params = (
850 controller => 'Part',
852 part_type => $::form->{add_item}->{create_part_type},
853 callback => $callback,
857 $self->redirect_to(@redirect_params);
860 sub action_return_from_create_part {
863 $self->{created_part} = SL::DB::Part->new(id => delete $::form->{new_parts_id})->load if $::form->{new_parts_id};
865 $::auth->restore_form_from_session(delete $::form->{previousform});
867 # set item ids to new fake id, to identify them as new items
868 foreach my $item (@{$self->order->items_sorted}) {
869 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
872 $self->get_unalterable_data();
875 # trigger rendering values for second row/longdescription as hidden,
876 # because they are loaded only on demand. So we need to keep the values
878 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
879 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
882 'delivery_order/form',
883 title => $self->get_title_for('edit'),
884 %{$self->{template_args}}
889 sub action_stock_in_out_dialog {
892 my $part = SL::DB::Part->load_cached($::form->{parts_id}) or die "need parts_id";
893 my $stock = $::form->{stock};
894 my $unit = $::form->{unit};
895 my $qty = _parse_number($::form->{qty_as_number});
897 my $inout = $self->type_data->transfer;
899 my @contents = DO->get_item_availability(parts_id => $part->id);
900 my $stock_info = DO->unpack_stock_information(packed => $stock);
902 $self->merge_stock_data($stock_info, \@contents, $part);
904 $self->render("delivery_order/stock_dialog", { layout => 0 },
905 WHCONTENTS => $self->order->delivered ? $stock_info : \@contents,
913 sub merge_stock_data {
914 my ($self, $stock_info, $contents, $part) = @_;
915 # TODO rewrite to mapping
917 if (!$self->order->delivered) {
918 for my $row (@$contents) {
919 $row->{available_qty} = _format_number_units($row->{qty}, $row->{unit}, $part->unit);
921 for my $sinfo (@{ $stock_info }) {
922 next if $row->{bin_id} != $sinfo->{bin_id} ||
923 $row->{warehouse_id} != $sinfo->{warehouse_id} ||
924 $row->{chargenumber} ne $sinfo->{chargenumber} ||
925 $row->{bestbefore} ne $sinfo->{bestbefore};
927 $row->{"stock_$_"} = $sinfo->{$_}
928 for qw(qty unit error delivery_order_items_stock_id);
933 for my $sinfo (@{ $stock_info }) {
934 my $bin = SL::DB::Bin->load_cached($sinfo->{bin_id});
935 $sinfo->{warehouse_description} = $bin->warehouse->description;
936 $sinfo->{bin_description} = $bin->escription;
937 map { $sinfo->{"stock_$_"} = $sinfo->{$_} } qw(qty unit);
942 # load the second row for one or more items
944 # This action gets the html code for all items second rows by rendering a template for
945 # the second row and sets the html code via client js.
946 sub action_load_second_rows {
949 foreach my $item_id (@{ $::form->{item_ids} }) {
950 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
951 my $item = $self->order->items_sorted->[$idx];
953 $self->js_load_second_row($item, $item_id, 0);
956 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
961 # update description, notes and sellprice from master data
962 sub action_update_row_from_master_data {
965 foreach my $item_id (@{ $::form->{item_ids} }) {
966 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
967 my $item = $self->order->items_sorted->[$idx];
968 my $texts = get_part_texts($item->part, $self->order->language_id);
970 $item->description($texts->{description});
971 $item->longdescription($texts->{longdescription});
973 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
976 if ($item->part->is_assortment) {
977 # add assortment items with price 0, as the components carry the price
978 $price_src = $price_source->price_from_source("");
979 $price_src->price(0);
981 $price_src = $price_source->best_price
982 ? $price_source->best_price
983 : $price_source->price_from_source("");
984 $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate;
985 $price_src->price(0) if !$price_source->best_price;
989 $item->sellprice($price_src->price);
990 $item->active_price_source($price_src);
993 ->run('kivi.Order.update_sellprice', $item_id, $item->sellprice_as_number)
994 ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
995 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
996 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
998 if ($self->search_cvpartnumber) {
999 $self->get_item_cvpartnumber($item);
1000 $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
1004 $self->js_redisplay_line_values;
1006 $self->js->render();
1009 sub action_transfer_stock {
1012 if ($self->order->delivered) {
1013 return $self->js->flash("error", t8('The parts for this order have already been transferred'))->render;
1016 my $errors = $self->save;
1019 $self->js->flash('error', $_) for @$errors;
1020 return $self->js->render;
1023 my $order = $self->order;
1025 # TODO move to type data
1026 my $trans_type = $self->type_data->properties('transfer') eq 'in'
1027 ? SL::DB::Manager::TransferType->find_by(direction => "id", description => "stock")
1028 : SL::DB::Manager::TransferType->find_by(direction => "out", deescription => "shipped");
1030 my @transfer_requests;
1032 for my $item (@{ $order->items_sorted }) {
1033 for my $stock (@{ $item->delivery_order_stock_entries }) {
1034 my $transfer = SL::DB::Inventory->new_from($stock);
1035 $transfer->trans_type($trans_type);
1037 push @transfer_requests, $transfer;
1041 if (!@transfer_requests) {
1042 $self->js->flash("error", t8("No stock to transfer"))->render;
1045 SL::DB->with_transaction(sub {
1046 $_->save for @transfer_requests;
1047 $self->order->update_attributes(deliverd => 1);
1050 $self->js->flash("info", t8("Stock transfered"))->render;
1053 sub js_load_second_row {
1054 my ($self, $item, $item_id, $do_parse) = @_;
1057 # Parse values from form (they are formated while rendering (template)).
1058 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1059 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1060 foreach my $var (@{ $item->cvars_by_config }) {
1061 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1063 $item->parse_custom_variable_values;
1066 my $row_as_html = $self->p->render('delivery_order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1069 ->html('#second_row_' . $item_id, $row_as_html)
1070 ->data('#second_row_' . $item_id, 'loaded', 1);
1073 sub js_redisplay_line_values {
1076 my $is_sales = $self->order->is_sales;
1078 # sales orders with margins
1083 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1084 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1085 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1086 ]} @{ $self->order->items_sorted };
1090 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1091 ]} @{ $self->order->items_sorted };
1095 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1098 sub js_redisplay_cvpartnumbers {
1101 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1103 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1106 ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
1109 sub js_reset_order_and_item_ids_after_save {
1113 ->val('#id', $self->order->id)
1114 ->val('#converted_from_oe_id', '')
1115 ->val('#order_' . $self->nr_key(), $self->order->number);
1118 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1119 next if !$self->order->items_sorted->[$idx]->id;
1120 next if $form_item_id !~ m{^new};
1122 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1123 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1124 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1128 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1138 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1139 die "Not a valid type for delivery order";
1142 $self->type($::form->{type});
1148 return $self->type_data->customervendor;
1151 sub init_search_cvpartnumber {
1154 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1155 my $search_cvpartnumber;
1156 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1157 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1159 return $search_cvpartnumber;
1162 sub init_show_update_button {
1165 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1176 sub init_all_price_factors {
1177 SL::DB::Manager::PriceFactor->get_all;
1180 sub init_part_picker_classification_ids {
1183 return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => $self->type_data->part_classification_query) } ];
1189 $::auth->assert($self->type_data->access || 'DOES_NOT_EXIST');
1192 # build the selection box for contacts
1194 # Needed, if customer/vendor changed.
1195 sub build_contact_select {
1198 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1199 value_key => 'cp_id',
1200 title_key => 'full_name_dep',
1201 default => $self->order->cp_id,
1203 style => 'width: 300px',
1207 # build the selection box for shiptos
1209 # Needed, if customer/vendor changed.
1210 sub build_shipto_select {
1213 select_tag('order.shipto_id',
1214 [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
1215 value_key => 'shipto_id',
1216 title_key => 'displayable_id',
1217 default => $self->order->shipto_id,
1219 style => 'width: 300px',
1223 # build the inputs for the cusom shipto dialog
1225 # Needed, if customer/vendor changed.
1226 sub build_shipto_inputs {
1229 my $content = $self->p->render('common/_ship_to_dialog',
1230 vc_obj => $self->order->customervendor,
1231 cs_obj => $self->order->custom_shipto,
1232 cvars => $self->order->custom_shipto->cvars_by_config,
1233 id_selector => '#order_shipto_id');
1235 div_tag($content, id => 'shipto_inputs');
1238 # render the info line for business
1240 # Needed, if customer/vendor changed.
1241 sub build_business_info_row
1243 $_[0]->p->render('delivery_order/tabs/_business_info_row', SELF => $_[0]);
1250 return if !$::form->{id};
1252 $self->order(SL::DB::DeliveryOrder->new(id => $::form->{id})->load);
1254 # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
1255 # You need a custom shipto object to call cvars_by_config to get the cvars.
1256 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
1258 return $self->order;
1261 # load or create a new order object
1263 # And assign changes from the form to this object.
1264 # If the order is loaded from db, check if items are deleted in the form,
1265 # remove them form the object and collect them for removing from db on saving.
1266 # Then create/update items from form (via make_item) and add them.
1270 # add_items adds items to an order with no items for saving, but they cannot
1271 # be retrieved via items until the order is saved. Adding empty items to new
1272 # order here solves this problem.
1274 $order = SL::DB::DeliveryOrder->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1275 $order ||= SL::DB::DeliveryOrder->new(orderitems => [], currency_id => $::instance_conf->get_currency_id(), order_type => $self->type_data->validate($::form->{type}));
1277 my $cv_id_method = $self->cv . '_id';
1278 if (!$::form->{id} && $::form->{$cv_id_method}) {
1279 $order->$cv_id_method($::form->{$cv_id_method});
1280 setup_order_from_cv($order);
1283 my $form_orderitems = delete $::form->{order}->{orderitems};
1285 $order->assign_attributes(%{$::form->{order}});
1287 $self->setup_custom_shipto_from_form($order, $::form);
1289 # remove deleted items
1290 $self->item_ids_to_delete([]);
1291 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1292 my $item = $order->orderitems->[$idx];
1293 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1294 splice @{$order->orderitems}, $idx, 1;
1295 push @{$self->item_ids_to_delete}, $item->id;
1301 foreach my $form_attr (@{$form_orderitems}) {
1302 my $item = make_item($order, $form_attr);
1303 $item->position($pos);
1307 $order->add_items(grep {!$_->id} @items);
1312 # create or update items from form
1314 # Make item objects from form values. For items already existing read from db.
1315 # Create a new item else. And assign attributes.
1317 my ($record, $attr) = @_;
1320 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1322 my $is_new = !$item;
1324 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1325 # they cannot be retrieved via custom_variables until the order/orderitem is
1326 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1327 $item ||= SL::DB::DeliveryOrderItem->new(custom_variables => []);
1329 $item->assign_attributes(%$attr);
1332 my $texts = get_part_texts($item->part, $record->language_id);
1333 $item->longdescription($texts->{longdescription}) if !defined $attr->{longdescription};
1334 $item->project_id($record->globalproject_id) if !defined $attr->{project_id};
1335 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number};
1343 # This is used to add one item
1345 my ($record, $attr) = @_;
1347 my $item = SL::DB::DeliveryOrderItem->new;
1349 # Remove attributes where the user left or set the inputs empty.
1350 # So these attributes will be undefined and we can distinguish them
1351 # from zero later on.
1352 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1353 delete $attr->{$_} if $attr->{$_} eq '';
1356 $item->assign_attributes(%$attr);
1358 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1359 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1361 $item->unit($part->unit) if !$item->unit;
1364 if ( $part->is_assortment ) {
1365 # add assortment items with price 0, as the components carry the price
1366 $price_src = $price_source->price_from_source("");
1367 $price_src->price(0);
1368 } elsif (defined $item->sellprice) {
1369 $price_src = $price_source->price_from_source("");
1370 $price_src->price($item->sellprice);
1372 $price_src = $price_source->best_price
1373 ? $price_source->best_price
1374 : $price_source->price_from_source("");
1375 $price_src->price(0) if !$price_source->best_price;
1379 if (defined $item->discount) {
1380 $discount_src = $price_source->discount_from_source("");
1381 $discount_src->discount($item->discount);
1383 $discount_src = $price_source->best_discount
1384 ? $price_source->best_discount
1385 : $price_source->discount_from_source("");
1386 $discount_src->discount(0) if !$price_source->best_discount;
1390 $new_attr{part} = $part;
1391 $new_attr{description} = $part->description if ! $item->description;
1392 $new_attr{qty} = 1.0 if ! $item->qty;
1393 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1394 $new_attr{sellprice} = $price_src->price;
1395 $new_attr{discount} = $discount_src->discount;
1396 $new_attr{active_price_source} = $price_src;
1397 $new_attr{active_discount_source} = $discount_src;
1398 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1399 $new_attr{project_id} = $record->globalproject_id;
1400 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1402 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1403 # they cannot be retrieved via custom_variables until the order/orderitem is
1404 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1405 $new_attr{custom_variables} = [];
1407 my $texts = get_part_texts($part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription});
1409 $item->assign_attributes(%new_attr, %{ $texts });
1414 sub setup_order_from_cv {
1417 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id));
1419 $order->intnotes($order->customervendor->notes);
1421 if ($order->is_sales) {
1422 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1423 $order->taxincluded(defined($order->customer->taxincluded_checked)
1424 ? $order->customer->taxincluded_checked
1425 : $::myconfig{taxincluded_checked});
1430 # setup custom shipto from form
1432 # The dialog returns form variables starting with 'shipto' and cvars starting
1433 # with 'shiptocvar_'.
1434 # Mark it to be deleted if a shipto from master data is selected
1435 # (i.e. order has a shipto).
1436 # Else, update or create a new custom shipto. If the fields are empty, it
1437 # will not be saved on save.
1438 sub setup_custom_shipto_from_form {
1439 my ($self, $order, $form) = @_;
1441 if ($order->shipto) {
1442 $self->is_custom_shipto_to_delete(1);
1444 my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1446 my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
1447 my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
1449 $custom_shipto->assign_attributes(%$shipto_attrs);
1450 $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
1454 # get data for saving, printing, ..., that is not changed in the form
1456 # Only cvars for now.
1457 sub get_unalterable_data {
1460 foreach my $item (@{ $self->order->items }) {
1461 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1462 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1463 foreach my $var (@{ $item->cvars_by_config }) {
1464 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1466 $item->parse_custom_variable_values;
1472 # And remove related files in the spool directory
1477 my $db = $self->order->db;
1479 $db->with_transaction(
1481 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1482 $self->order->delete;
1483 my $spool = $::lx_office_conf{paths}->{spool};
1484 unlink map { "$spool/$_" } @spoolfiles if $spool;
1486 $self->save_history('DELETED');
1489 }) || push(@{$errors}, $db->error);
1496 # And delete items that are deleted in the form.
1501 my $db = $self->order->db;
1503 $db->with_transaction(sub {
1504 # delete custom shipto if it is to be deleted or if it is empty
1505 if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
1506 $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
1507 $self->order->custom_shipto(undef);
1510 SL::DB::DeliveryOrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1511 $self->order->save(cascade => 1);
1514 if ($::form->{converted_from_oe_id}) {
1515 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1516 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1517 my $src = SL::DB::DeliveryOrder->new(id => $converted_from_oe_id)->load;
1518 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
1519 $src->link_to_record($self->order);
1521 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1523 foreach (@{ $self->order->items_sorted }) {
1524 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1526 SL::DB::RecordLink->new(from_table => 'orderitems',
1527 from_id => $from_id,
1528 to_table => 'orderitems',
1536 $self->save_history('SAVED');
1539 }) || push(@{$errors}, $db->error);
1544 sub workflow_sales_or_request_for_quotation {
1548 my $errors = $self->save();
1550 if (scalar @{ $errors }) {
1551 $self->js->flash('error', $_) for @{ $errors };
1552 return $self->js->render();
1555 my $destination_type = $self->type_data->workflow("to_quotation_type");
1557 $self->order(SL::DB::DeliveryOrder->new_from($self->order, destination_type => $destination_type));
1558 $self->{converted_from_oe_id} = delete $::form->{id};
1560 # set item ids to new fake id, to identify them as new items
1561 foreach my $item (@{$self->order->items_sorted}) {
1562 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1566 $::form->{type} = $destination_type;
1567 $self->type($self->init_type);
1568 $self->cv ($self->init_cv);
1571 $self->get_unalterable_data();
1572 $self->pre_render();
1574 # trigger rendering values for second row as hidden, because they
1575 # are loaded only on demand. So we need to keep the values from the
1577 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1580 'delivery_order/form',
1581 title => $self->get_title_for('edit'),
1582 %{$self->{template_args}}
1586 sub workflow_sales_or_purchase_order {
1590 my $errors = $self->save();
1592 if (scalar @{ $errors }) {
1593 $self->js->flash('error', $_) foreach @{ $errors };
1594 return $self->js->render();
1597 my $destination_type = $self->type_data->workflow("to_order_type");
1599 # check for direct delivery
1600 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
1602 if ($self->type_data->workflow("to_order_copy_shipto") && $::form->{use_shipto} && $self->order->shipto) {
1603 $custom_shipto = $self->order->shipto->clone('SL::DB::DeliveryOrder');
1606 $self->order(SL::DB::DeliveryOrder->new_from($self->order, destination_type => $destination_type));
1607 $self->{converted_from_oe_id} = delete $::form->{id};
1609 # set item ids to new fake id, to identify them as new items
1610 foreach my $item (@{$self->order->items_sorted}) {
1611 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1614 if ($self->type_data->workflow("to_order_copy_shipto")) {
1615 if ($::form->{use_shipto}) {
1616 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
1618 # remove any custom shipto if not wanted
1619 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1624 $::form->{type} = $destination_type;
1625 $self->type($self->init_type);
1626 $self->cv ($self->init_cv);
1629 $self->get_unalterable_data();
1630 $self->pre_render();
1632 # trigger rendering values for second row as hidden, because they
1633 # are loaded only on demand. So we need to keep the values from the
1635 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1638 'delivery_order/form',
1639 title => $self->get_title_for('edit'),
1640 %{$self->{template_args}}
1647 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1648 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
1649 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1650 $self->{all_languages} = SL::DB::Manager::Language->get_all_sorted();
1651 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1654 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1657 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1659 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1660 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1661 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1662 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1664 my $print_form = Form->new('');
1665 $print_form->{type} = $self->type;
1666 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1667 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1668 form => $print_form,
1669 options => {dialog_name_prefix => 'print_options.',
1673 no_opendocument => 0,
1677 foreach my $item (@{$self->order->orderitems}) {
1678 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1679 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1680 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1683 if ($self->order->${\ $self->type_data->nr_key } && $::instance_conf->get_webdav) {
1684 my $webdav = SL::Webdav->new(
1685 type => $self->type,
1686 number => $self->order->number,
1688 my @all_objects = $webdav->get_all_objects;
1689 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1691 link => File::Spec->catfile($_->full_filedescriptor),
1695 $self->{template_args}{in_out} = $self->type_data->transfer;
1697 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1699 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.DeliveryOrder kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
1700 calculate_qty kivi.Validator follow_up show_history);
1701 $self->setup_edit_action_bar;
1704 sub setup_edit_action_bar {
1705 my ($self, %params) = @_;
1707 my $deletion_allowed = $self->type_data->show_menu("delete");
1709 for my $bar ($::request->layout->get('actionbar')) {
1714 call => [ 'kivi.DeliveryOrder.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1715 $::instance_conf->get_order_warn_no_deliverydate,
1720 call => [ 'kivi.DeliveryOrder.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1721 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1723 ], # end of combobox "Save"
1730 t8('Save and Quotation'),
1731 submit => [ '#order_form', { action => "DeliveryOrder/sales_quotation" } ],
1732 only_if => $self->type_data->show_menu("save_and_quotation"),
1736 submit => [ '#order_form', { action => "DeliveryOrder/request_for_quotation" } ],
1737 only_if => $self->type_data->show_menu("save_and_rfq"),
1740 t8('Save and Sales Order'),
1741 submit => [ '#order_form', { action => "DeliveryOrder/sales_order" } ],
1742 only_if => $self->type_data->show_menu("save_and_sales_order"),
1745 t8('Save and Purchase Order'),
1746 call => [ 'kivi.DeliveryOrder.purchase_order_check_for_direct_delivery' ],
1747 only_if => $self->type_data->show_menu("save_and_purchase_order"),
1750 t8('Save and Delivery Order'),
1751 call => [ 'kivi.DeliveryOrder.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1752 $::instance_conf->get_order_warn_no_deliverydate,
1754 only_if => $self->type_data->show_menu("save_and_delivery_order"),
1757 t8('Save and Invoice'),
1758 call => [ 'kivi.DeliveryOrder.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1759 only_if => $self->type_data->show_menu("save_and_invoice"),
1762 t8('Save and AP Transaction'),
1763 call => [ 'kivi.DeliveryOrder.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
1764 only_if => $self->type_data->show_menu("save_and_ap_transaction"),
1767 ], # end of combobox "Workflow"
1774 t8('Save and preview PDF'),
1775 call => [ 'kivi.DeliveryOrder.save', 'preview_pdf', $::instance_conf->get_order_warn_duplicate_parts,
1776 $::instance_conf->get_order_warn_no_deliverydate,
1780 t8('Save and print'),
1781 call => [ 'kivi.DeliveryOrder.show_print_options', $::instance_conf->get_order_warn_duplicate_parts,
1782 $::instance_conf->get_order_warn_no_deliverydate,
1786 t8('Save and E-mail'),
1787 id => 'save_and_email_action',
1788 call => [ 'kivi.DeliveryOrder.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts,
1789 $::instance_conf->get_order_warn_no_deliverydate,
1791 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1794 t8('Download attachments of all parts'),
1795 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1796 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1797 only_if => $::instance_conf->get_doc_storage,
1799 ], # end of combobox "Export"
1803 call => [ 'kivi.DeliveryOrder.delete_order' ],
1804 confirm => $::locale->text('Do you really want to delete this object?'),
1805 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1806 only_if => $self->type_data->show_menu("delete"),
1812 submit => [ '#order_form', { action => "DeliveryOrder/transfer_stock" } ],
1813 disabled => $self->order->delivered ? t8('The parts for this order have already been transferred') : undef,
1814 only_if => $self->type_data->properties('transfer') eq 'out',
1815 confirm => t8('Do you really want to transfer the stock and set this order to delivered?'),
1819 submit => [ '#order_form', { action => "DeliveryOrder/transfer_stock" } ],
1820 disabled => $self->order->delivered ? t8('The parts for this order have already been transferred') : undef,
1821 only_if => $self->type_data->properties('transfer') eq 'in',
1822 confirm => t8('Do you really want to transfer the stock and set this order to delivered?'),
1832 call => [ 'kivi.DeliveryOrder.follow_up_window' ],
1833 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1834 only_if => $::auth->assert('productivity', 1),
1838 call => [ 'set_history_window', $self->order->id, 'id' ],
1839 disabled => !$self->order->id ? t8('This record has not been saved yet.') : undef,
1841 ], # end of combobox "more"
1847 my ($order, $pdf_ref, $params) = @_;
1851 my $print_form = Form->new('');
1852 $print_form->{type} = $order->type;
1853 $print_form->{formname} = $params->{formname} || $order->type;
1854 $print_form->{format} = $params->{format} || 'pdf';
1855 $print_form->{media} = $params->{media} || 'file';
1856 $print_form->{groupitems} = $params->{groupitems};
1857 $print_form->{printer_id} = $params->{printer_id};
1858 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1860 $order->language($params->{language});
1861 $order->flatten_to_form($print_form, format_amounts => 1);
1865 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
1866 $template_ext = 'odt';
1867 $template_type = 'OpenDocument';
1870 # search for the template
1871 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1872 name => $print_form->{formname},
1873 extension => $template_ext,
1874 email => $print_form->{media} eq 'email',
1875 language => $params->{language},
1876 printer_id => $print_form->{printer_id},
1879 if (!defined $template_file) {
1880 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);
1883 return @errors if scalar @errors;
1885 $print_form->throw_on_error(sub {
1887 $print_form->prepare_for_printing;
1889 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1890 format => $print_form->{format},
1891 template_type => $template_type,
1892 template => $template_file,
1893 variables => $print_form,
1894 variable_content_types => {
1895 longdescription => 'html',
1896 partnotes => 'html',
1901 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
1907 sub get_files_for_email_dialog {
1910 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
1912 return %files if !$::instance_conf->get_doc_storage;
1914 if ($self->order->id) {
1915 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
1916 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
1917 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
1918 $files{project_files} = [ SL::File->get_all( object_id => $self->order->globalproject_id, object_type => 'project', file_type => 'attachment') ];
1922 uniq_by { $_->{id} }
1924 +{ id => $_->part->id,
1925 partnumber => $_->part->partnumber }
1926 } @{$self->order->items_sorted};
1928 foreach my $part (@parts) {
1929 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
1930 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
1933 foreach my $key (keys %files) {
1934 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
1941 my ($self, $action) = @_;
1943 return '' if none { lc($action)} qw(add edit);
1944 return $self->type_data->text($action);
1947 sub get_item_cvpartnumber {
1948 my ($self, $item) = @_;
1950 return if !$self->search_cvpartnumber;
1951 return if !$self->order->customervendor;
1953 if ($self->cv eq 'vendor') {
1954 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
1955 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
1956 } elsif ($self->cv eq 'customer') {
1957 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
1958 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
1962 sub get_part_texts {
1963 my ($part_or_id, $language_or_id, %defaults) = @_;
1965 my $part = ref($part_or_id) ? $part_or_id : SL::DB::Part->load_cached($part_or_id);
1966 my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id;
1968 description => $defaults{description} // $part->description,
1969 longdescription => $defaults{longdescription} // $part->notes,
1972 return $texts unless $language_id;
1974 my $translation = SL::DB::Manager::Translation->get_first(
1976 parts_id => $part->id,
1977 language_id => $language_id,
1980 $texts->{description} = $translation->translation if $translation && $translation->translation;
1981 $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription;
1987 return $_[0]->type_data->nr_key;
1990 sub save_and_redirect_to {
1991 my ($self, %params) = @_;
1993 my $errors = $self->save();
1995 if (scalar @{ $errors }) {
1996 $self->js->flash('error', $_) foreach @{ $errors };
1997 return $self->js->render();
2000 flash_later('info', $self->type_data->text("saved"));
2002 $self->redirect_to(%params, id => $self->order->id);
2006 my ($self, $addition) = @_;
2008 my $number_type = $self->nr_key;
2009 my $snumbers = $number_type . '_' . $self->order->$number_type;
2011 SL::DB::History->new(
2012 trans_id => $self->order->id,
2013 employee_id => SL::DB::Manager::Employee->current->id,
2014 what_done => $self->order->type,
2015 snumbers => $snumbers,
2016 addition => $addition,
2020 sub store_pdf_to_webdav_and_filemanagement {
2021 my($order, $content, $filename) = @_;
2025 # copy file to webdav folder
2026 if ($order->number && $::instance_conf->get_webdav_documents) {
2027 my $webdav = SL::Webdav->new(
2028 type => $order->type,
2029 number => $order->number,
2031 my $webdav_file = SL::Webdav::File->new(
2033 filename => $filename,
2036 $webdav_file->store(data => \$content);
2039 push @errors, t8('Storing PDF to webdav folder failed: #1', $@);
2042 if ($order->id && $::instance_conf->get_doc_storage) {
2044 SL::File->save(object_id => $order->id,
2045 object_type => $order->type,
2046 mime_type => 'application/pdf',
2047 source => 'created',
2048 file_type => 'document',
2049 file_name => $filename,
2050 file_contents => $content);
2053 push @errors, t8('Storing PDF in storage backend failed: #1', $@);
2060 sub calculate_stock_in_out {
2061 my ($self, $item) = @_;
2063 return "" if !$item->part || !$item->part->unit || !$item->unit;
2065 my $in_out = $self->type_data->transfer;
2067 my $do_qty = $item->qty;
2068 my $sum = sum0 map {
2069 $_->unit_obj->convert_to($_->qty, $item->unit_obj)
2070 } $item->delivery_order_stock_entries;
2072 my $matches = $do_qty == $sum;
2073 my $content = _format_number_units($sum, 2, $item->unit_obj, $item->part->unit_obj);
2078 sub init_type_data {
2079 SL::Controller::DeliveryOrder::TypeData->new($_[0]);
2082 sub init_valid_types {
2083 $_[0]->type_data->valid_types;
2094 SL::Controller::Order - controller for orders
2098 This is a new form to enter orders, completely rewritten with the use
2099 of controller and java script techniques.
2101 The aim is to provide the user a better experience and a faster workflow. Also
2102 the code should be more readable, more reliable and better to maintain.
2110 One input row, so that input happens every time at the same place.
2114 Use of pickers where possible.
2118 Possibility to enter more than one item at once.
2122 Item list in a scrollable area, so that the workflow buttons stay at
2127 Reordering item rows with drag and drop is possible. Sorting item rows is
2128 possible (by partnumber, description, qty, sellprice and discount for now).
2132 No C<update> is necessary. All entries and calculations are managed
2133 with ajax-calls and the page only reloads on C<save>.
2137 User can see changes immediately, because of the use of java script
2148 =item * C<SL/Controller/Order.pm>
2152 =item * C<template/webpages/delivery_order/form.html>
2156 =item * C<template/webpages/delivery_order/tabs/basic_data.html>
2158 Main tab for basic_data.
2160 This is the only tab here for now. "linked records" and "webdav" tabs are
2161 reused from generic code.
2165 =item * C<template/webpages/delivery_order/tabs/_business_info_row.html>
2167 For displaying information on business type
2169 =item * C<template/webpages/delivery_order/tabs/_item_input.html>
2171 The input line for items
2173 =item * C<template/webpages/delivery_order/tabs/_row.html>
2175 One row for already entered items
2177 =item * C<template/webpages/delivery_order/tabs/_tax_row.html>
2179 Displaying tax information
2183 =item * C<js/kivi.DeliveryOrder.js>
2185 java script functions
2195 =item * price sources: little symbols showing better price / better discount
2197 =item * select units in input row?
2199 =item * check for direct delivery (workflow sales order -> purchase order)
2201 =item * access rights
2203 =item * display weights
2207 =item * optional client/user behaviour
2209 (transactions has to be set - department has to be set -
2210 force project if enabled in client config - transport cost reminder)
2214 =head1 KNOWN BUGS AND CAVEATS
2220 Customer discount is not displayed as a valid discount in price source popup
2221 (this might be a bug in price sources)
2223 (I cannot reproduce this (Bernd))
2227 No indication that <shift>-up/down expands/collapses second row.
2231 Inline creation of parts is not currently supported
2235 Table header is not sticky in the scrolling area.
2239 Sorting does not include C<position>, neither does reordering.
2241 This behavior was implemented intentionally. But we can discuss, which behavior
2242 should be implemented.
2246 =head1 To discuss / Nice to have
2252 How to expand/collapse second row. Now it can be done clicking the icon or
2257 Possibility to select PriceSources in input row?
2261 This controller uses a (changed) copy of the template for the PriceSource
2262 dialog. Maybe there could be used one code source.
2266 Rounding-differences between this controller (PriceTaxCalculator) and the old
2267 form. This is not only a problem here, but also in all parts using the PTC.
2268 There exists a ticket and a patch. This patch should be testet.
2272 An indicator, if the actual inputs are saved (like in an
2273 editor or on text processing application).
2277 A warning when leaving the page without saveing unchanged inputs.
2284 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>