1 package SL::Controller::Order;
4 use parent qw(SL::Controller::Base);
6 use SL::Helper::Flash qw(flash_later);
7 use SL::Presenter::Tag qw(select_tag);
8 use SL::Locale::String qw(t8);
9 use SL::SessionFile::Random;
21 use SL::Helper::CreatePDF qw(:all);
22 use SL::Helper::PrintOptions;
24 use SL::Controller::Helper::GetModels;
26 use List::Util qw(first);
27 use List::MoreUtils qw(none pairwise first_index);
28 use English qw(-no_match_vars);
31 use Rose::Object::MakeMethods::Generic
33 scalar => [ qw(item_ids_to_delete) ],
34 'scalar --get_set_init' => [ qw(order valid_types type cv p multi_items_models all_price_factors) ],
39 __PACKAGE__->run_before('_check_auth');
41 __PACKAGE__->run_before('_recalc',
42 only => [ qw(save save_and_delivery_order save_and_invoice print create_pdf send_email) ]);
44 __PACKAGE__->run_before('_get_unalterable_data',
45 only => [ qw(save save_and_delivery_order save_and_invoice print create_pdf send_email) ]);
55 $self->order->transdate(DateTime->now_local());
56 $self->order->reqdate(DateTime->today_local->next_workday) if !$self->order->reqdate;
61 title => $self->type eq _sales_order_type() ? $::locale->text('Add Sales Order')
62 : $self->type eq _purchase_order_type() ? $::locale->text('Add Purchase Order')
64 %{$self->{template_args}}
68 # edit an existing order
77 title => $self->type eq _sales_order_type() ? $::locale->text('Edit Sales Order')
78 : $self->type eq _purchase_order_type() ? $::locale->text('Edit Purchase Order')
80 %{$self->{template_args}}
88 my $errors = $self->_delete();
90 if (scalar @{ $errors }) {
91 $self->js->flash('error', $_) foreach @{ $errors };
92 return $self->js->render();
95 flash_later('info', $::locale->text('The order has been deleted'));
96 my @redirect_params = (
101 $self->redirect_to(@redirect_params);
108 my $errors = $self->_save();
110 if (scalar @{ $errors }) {
111 $self->js->flash('error', $_) foreach @{ $errors };
112 return $self->js->render();
115 flash_later('info', $::locale->text('The order has been saved'));
116 my @redirect_params = (
119 id => $self->order->id,
122 $self->redirect_to(@redirect_params);
127 # This is called if "print" is pressed in the print dialog.
128 # If PDF creation was requested and succeeded, the pdf is stored in a session
129 # file and the filename is stored as session value with an unique key. A
130 # javascript function with this key is then called. This function calls the
131 # download action below (action_download_pdf), which offers the file for
136 my $format = $::form->{print_options}->{format};
137 my $media = $::form->{print_options}->{media};
138 my $formname = $::form->{print_options}->{formname};
139 my $copies = $::form->{print_options}->{copies};
140 my $groupitems = $::form->{print_options}->{groupitems};
143 if (none { $format eq $_ } qw(pdf)) {
144 return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
147 # only screen or printer by now
148 if (none { $media eq $_ } qw(screen printer)) {
149 return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
153 $language = SL::DB::Language->new(id => $::form->{print_options}->{language_id})->load if $::form->{print_options}->{language_id};
155 # create a form for generate_attachment_filename
156 my $form = Form->new;
157 $form->{ordnumber} = $self->order->ordnumber;
158 $form->{type} = $self->type;
159 $form->{format} = $format;
160 $form->{formname} = $formname;
161 $form->{language} = '_' . $language->template_code if $language;
162 my $pdf_filename = $form->generate_attachment_filename();
165 my @errors = _create_pdf($self->order, \$pdf, { format => $format,
166 formname => $formname,
167 language => $language,
168 groupitems => $groupitems });
169 if (scalar @errors) {
170 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
173 if ($media eq 'screen') {
175 my $sfile = SL::SessionFile::Random->new(mode => "w");
176 $sfile->fh->print($pdf);
179 my $key = join('_', Time::HiRes::gettimeofday(), int rand 1000000000000);
180 $::auth->set_session_value("Order::create_pdf-${key}" => $sfile->file_name);
183 ->run('kivi.Order.download_pdf', $pdf_filename, $key)
184 ->flash('info', t8('The PDF has been created'));
186 } elsif ($media eq 'printer') {
188 my $printer_id = $::form->{print_options}->{printer_id};
189 SL::DB::Printer->new(id => $printer_id)->load->print_document(
194 $self->js->flash('info', t8('The PDF has been printed'));
197 # copy file to webdav folder
198 if ($self->order->ordnumber && $::instance_conf->get_webdav_documents) {
199 my $webdav = SL::Webdav->new(
201 number => $self->order->ordnumber,
203 my $webdav_file = SL::Webdav::File->new(
205 filename => $pdf_filename,
208 $webdav_file->store(data => \$pdf);
211 $self->js->flash('error', t8('Storing PDF to webdav folder failed: #1', $@));
214 if ($self->order->ordnumber && $::instance_conf->get_doc_storage) {
216 SL::File->save(object_id => $self->order->id,
217 object_type => $self->type,
218 mime_type => 'application/pdf',
220 file_type => 'document',
221 file_name => $pdf_filename,
222 file_contents => $pdf);
225 $self->js->flash('error', t8('Storing PDF in storage backend failed: #1', $@));
231 # offer pdf for download
233 # It needs to get the key for the session value to get the pdf file.
234 sub action_download_pdf {
237 my $key = $::form->{key};
238 my $tmp_filename = $::auth->get_session_value("Order::create_pdf-${key}");
239 return $self->send_file(
241 type => 'application/pdf',
242 name => $::form->{pdf_filename},
246 # open the email dialog
247 sub action_show_email_dialog {
250 my $cv_method = $self->cv;
252 if (!$self->order->$cv_method) {
253 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'))
257 $self->{email}->{to} = $self->order->contact->cp_email if $self->order->contact;
258 $self->{email}->{to} ||= $self->order->$cv_method->email;
259 $self->{email}->{cc} = $self->order->$cv_method->cc;
260 $self->{email}->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
261 # Todo: get addresses from shipto, if any
263 my $form = Form->new;
264 $form->{ordnumber} = $self->order->ordnumber;
265 $form->{formname} = $self->type;
266 $form->{type} = $self->type;
267 $form->{language} = 'de';
268 $form->{format} = 'pdf';
270 $self->{email}->{subject} = $form->generate_email_subject();
271 $self->{email}->{attachment_filename} = $form->generate_attachment_filename();
272 $self->{email}->{message} = $form->create_email_signature();
274 my $dialog_html = $self->render('order/tabs/_email_dialog', { output => 0 });
276 ->run('kivi.Order.show_email_dialog', $dialog_html)
283 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
284 sub action_send_email {
287 my $mail = Mailer->new;
288 $mail->{from} = qq|"$::myconfig{name}" <$::myconfig{email}>|;
289 $mail->{$_} = $::form->{email}->{$_} for qw(to cc bcc subject message);
292 my @errors = _create_pdf($self->order, \$pdf, {media => 'email'});
293 if (scalar @errors) {
294 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
297 $mail->{attachments} = [{ "content" => $pdf,
298 "name" => $::form->{email}->{attachment_filename} }];
300 if (my $err = $mail->send) {
301 return $self->js->flash('error', t8('Sending E-mail: ') . $err)
306 my $intnotes = $self->order->intnotes;
307 $intnotes .= "\n\n" if $self->order->intnotes;
308 $intnotes .= t8('[email]') . "\n";
309 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
310 $intnotes .= t8('To (email)') . ": " . $mail->{to} . "\n";
311 $intnotes .= t8('Cc') . ": " . $mail->{cc} . "\n" if $mail->{cc};
312 $intnotes .= t8('Bcc') . ": " . $mail->{bcc} . "\n" if $mail->{bcc};
313 $intnotes .= t8('Subject') . ": " . $mail->{subject} . "\n\n";
314 $intnotes .= t8('Message') . ": " . $mail->{message};
317 ->val('#order_intnotes', $intnotes)
318 ->run('kivi.Order.close_email_dialog')
322 # save the order and redirect to the frontend subroutine for a new
324 sub action_save_and_delivery_order {
327 my $errors = $self->_save();
329 if (scalar @{ $errors }) {
330 $self->js->flash('error', $_) foreach @{ $errors };
331 return $self->js->render();
333 flash_later('info', $::locale->text('The order has been saved'));
335 my @redirect_params = (
336 controller => 'oe.pl',
337 action => 'oe_delivery_order_from_order',
338 id => $self->order->id,
341 $self->redirect_to(@redirect_params);
344 # save the order and redirect to the frontend subroutine for a new
346 sub action_save_and_invoice {
349 my $errors = $self->_save();
351 if (scalar @{ $errors }) {
352 $self->js->flash('error', $_) foreach @{ $errors };
353 return $self->js->render();
355 flash_later('info', $::locale->text('The order has been saved'));
357 my @redirect_params = (
358 controller => 'oe.pl',
359 action => 'oe_invoice_from_order',
360 id => $self->order->id,
363 $self->redirect_to(@redirect_params);
366 # set form elements in respect to a changed customer or vendor
368 # This action is called on an change of the customer/vendor picker.
369 sub action_customer_vendor_changed {
372 my $cv_method = $self->cv;
374 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
375 $self->js->show('#cp_row');
377 $self->js->hide('#cp_row');
380 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
381 $self->js->show('#shipto_row');
383 $self->js->hide('#shipto_row');
386 $self->order->taxzone_id($self->order->$cv_method->taxzone_id);
388 if ($self->order->is_sales) {
389 $self->order->taxincluded(defined($self->order->$cv_method->taxincluded_checked)
390 ? $self->order->$cv_method->taxincluded_checked
391 : $::myconfig{taxincluded_checked});
392 $self->js->val('#order_salesman_id', $self->order->$cv_method->salesman_id);
395 $self->order->payment_id($self->order->$cv_method->payment_id);
396 $self->order->delivery_term_id($self->order->$cv_method->delivery_term_id);
401 ->replaceWith('#order_cp_id', $self->build_contact_select)
402 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
403 ->val( '#order_taxzone_id', $self->order->taxzone_id)
404 ->val( '#order_taxincluded', $self->order->taxincluded)
405 ->val( '#order_payment_id', $self->order->payment_id)
406 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
407 ->val( '#order_intnotes', $self->order->$cv_method->notes)
408 ->focus( '#order_' . $self->cv . '_id');
410 $self->_js_redisplay_amounts_and_taxes;
414 # called if a unit in an existing item row is changed
415 sub action_unit_changed {
418 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
419 my $item = $self->order->items_sorted->[$idx];
421 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
422 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
427 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
428 $self->_js_redisplay_line_values;
429 $self->_js_redisplay_amounts_and_taxes;
433 # add an item row for a new item entered in the input row
434 sub action_add_item {
437 my $form_attr = $::form->{add_item};
439 return unless $form_attr->{parts_id};
441 my $item = _new_item($self->order, $form_attr);
443 $self->order->add_items($item);
447 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
448 my $row_as_html = $self->p->render('order/tabs/_row',
452 ALL_PRICE_FACTORS => $self->all_price_factors
456 ->append('#row_table_id', $row_as_html);
458 if ( $item->part->is_assortment ) {
459 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
460 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
461 my $attr = { parts_id => $assortment_item->parts_id,
462 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
463 unit => $assortment_item->unit,
464 description => $assortment_item->part->description,
466 my $item = _new_item($self->order, $attr);
468 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
469 $item->discount(1) unless $assortment_item->charge;
471 $self->order->add_items( $item );
473 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
474 my $row_as_html = $self->p->render('order/tabs/_row',
478 ALL_PRICE_FACTORS => $self->all_price_factors
481 ->append('#row_table_id', $row_as_html);
486 ->val('.add_item_input', '')
487 ->run('kivi.Order.init_row_handlers')
488 ->run('kivi.Order.row_table_scroll_down')
489 ->run('kivi.Order.renumber_positions')
490 ->focus('#add_item_parts_id_name');
492 $self->_js_redisplay_amounts_and_taxes;
496 # open the dialog for entering multiple items at once
497 sub action_show_multi_items_dialog {
498 require SL::DB::PartsGroup;
499 $_[0]->render('order/tabs/_multi_items_dialog', { layout => 0 },
500 all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
503 # update the filter results in the multi item dialog
504 sub action_multi_items_update_result {
507 $::form->{multi_items}->{filter}->{obsolete} = 0;
509 my $count = $_[0]->multi_items_models->count;
512 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
513 $_[0]->render($text, { layout => 0 });
514 } elsif ($count > $max_count) {
515 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
516 $_[0]->render($text, { layout => 0 });
518 my $multi_items = $_[0]->multi_items_models->get;
519 $_[0]->render('order/tabs/_multi_items_result', { layout => 0 },
520 multi_items => $multi_items);
524 # add item rows for multiple items at once
525 sub action_add_multi_items {
528 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_multi_items} };
529 return $self->js->render() unless scalar @form_attr;
532 foreach my $attr (@form_attr) {
533 my $item = _new_item($self->order, $attr);
535 if ( $item->part->is_assortment ) {
536 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
537 my $attr = { parts_id => $assortment_item->parts_id,
538 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
539 unit => $assortment_item->unit,
540 description => $assortment_item->part->description,
542 my $item = _new_item($self->order, $attr);
544 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
545 $item->discount(1) unless $assortment_item->charge;
550 $self->order->add_items(@items);
554 foreach my $item (@items) {
555 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
556 my $row_as_html = $self->p->render('order/tabs/_row',
560 ALL_PRICE_FACTORS => $self->all_price_factors
563 $self->js->append('#row_table_id', $row_as_html);
567 ->run('kivi.Order.close_multi_items_dialog')
568 ->run('kivi.Order.init_row_handlers')
569 ->run('kivi.Order.row_table_scroll_down')
570 ->run('kivi.Order.renumber_positions')
571 ->focus('#add_item_parts_id_name');
573 $self->_js_redisplay_amounts_and_taxes;
577 # recalculate all linetotals, amounts and taxes and redisplay them
578 sub action_recalc_amounts_and_taxes {
583 $self->_js_redisplay_line_values;
584 $self->_js_redisplay_amounts_and_taxes;
588 # redisplay item rows if they are sorted by an attribute
589 sub action_reorder_items {
593 partnumber => sub { $_[0]->part->partnumber },
594 description => sub { $_[0]->description },
595 qty => sub { $_[0]->qty },
596 sellprice => sub { $_[0]->sellprice },
597 discount => sub { $_[0]->discount },
600 my $method = $sort_keys{$::form->{order_by}};
601 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
602 if ($::form->{sort_dir}) {
603 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
605 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
608 ->run('kivi.Order.redisplay_items', \@to_sort)
612 # show the popup to choose a price/discount source
613 sub action_price_popup {
616 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
617 my $item = $self->order->items_sorted->[$idx];
619 $self->render_price_dialog($item);
622 # get the longdescription for an item if the dialog to enter/change the
623 # longdescription was opened and the longdescription is empty
625 # If this item is new, get the longdescription from Part.
626 # Otherwise get it from OrderItem.
627 sub action_get_item_longdescription {
630 if ($::form->{item_id}) {
631 $longdescription = SL::DB::OrderItem->new(id => $::form->{item_id})->load->longdescription;
632 } elsif ($::form->{parts_id}) {
633 $longdescription = SL::DB::Part->new(id => $::form->{parts_id})->load->notes;
635 $_[0]->render(\ $longdescription, { type => 'text' });
638 # load the second row for one or more items
640 # This action gets the html code for all items second rows by rendering a template for
641 # the second row and sets the html code via client js.
642 sub action_load_second_rows {
645 $self->_recalc() if $self->order->is_sales; # for margin calculation
647 foreach my $item_id (@{ $::form->{item_ids} }) {
648 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
649 my $item = $self->order->items_sorted->[$idx];
651 $self->_js_load_second_row($item, $item_id, 0);
654 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
659 sub _js_load_second_row {
660 my ($self, $item, $item_id, $do_parse) = @_;
663 # Parse values from form (they are formated while rendering (template)).
664 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
665 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
666 foreach my $var (@{ $item->cvars_by_config }) {
667 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
669 $item->parse_custom_variable_values;
672 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
675 ->html('.row_entry:has(#item_' . $item_id . ') [name = "second_row"]', $row_as_html)
676 ->data('.row_entry:has(#item_' . $item_id . ') [name = "second_row"]', 'loaded', 1);
679 sub _js_redisplay_line_values {
682 my $is_sales = $self->order->is_sales;
684 # sales orders with margins
689 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
690 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
691 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
692 ]} @{ $self->order->items_sorted };
696 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
697 ]} @{ $self->order->items_sorted };
701 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
704 sub _js_redisplay_amounts_and_taxes {
707 if (scalar @{ $self->{taxes} }) {
708 $self->js->show('#taxincluded_row_id');
710 $self->js->hide('#taxincluded_row_id');
713 if ($self->order->taxincluded) {
714 $self->js->hide('#subtotal_row_id');
716 $self->js->show('#subtotal_row_id');
720 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
721 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
723 ->insertBefore($self->build_tax_rows, '#amount_row_id');
730 sub init_valid_types {
731 [ _sales_order_type(), _purchase_order_type() ];
737 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
738 die "Not a valid type for order";
741 $self->type($::form->{type});
747 my $cv = $self->type eq _sales_order_type() ? 'customer'
748 : $self->type eq _purchase_order_type() ? 'vendor'
749 : die "Not a valid type for order";
762 # model used to filter/display the parts in the multi-items dialog
763 sub init_multi_items_models {
764 SL::Controller::Helper::GetModels->new(
767 with_objects => [ qw(unit_obj) ],
768 disable_plugin => 'paginated',
769 source => $::form->{multi_items},
775 partnumber => t8('Partnumber'),
776 description => t8('Description')}
780 sub init_all_price_factors {
781 SL::DB::Manager::PriceFactor->get_all;
787 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
789 my $right = $right_for->{ $self->type };
790 $right ||= 'DOES_NOT_EXIST';
792 $::auth->assert($right);
795 # build the selection box for contacts
797 # Needed, if customer/vendor changed.
798 sub build_contact_select {
801 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
802 value_key => 'cp_id',
803 title_key => 'full_name_dep',
804 default => $self->order->cp_id,
806 style => 'width: 300px',
810 # build the selection box for shiptos
812 # Needed, if customer/vendor changed.
813 sub build_shipto_select {
816 select_tag('order.shipto_id', [ $self->order->{$self->cv}->shipto ],
817 value_key => 'shipto_id',
818 title_key => 'displayable_id',
819 default => $self->order->shipto_id,
821 style => 'width: 300px',
825 # build the rows for displaying taxes
827 # Called if amounts where recalculated and redisplayed.
832 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
833 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
835 return $rows_as_html;
839 sub render_price_dialog {
840 my ($self, $record_item) = @_;
842 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
846 'kivi.io.price_chooser_dialog',
847 t8('Available Prices'),
848 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
853 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
854 # $self->js->show('#dialog_flash_error');
863 return if !$::form->{id};
865 $self->order(SL::DB::Manager::Order->find_by(id => $::form->{id}));
868 # load or create a new order object
870 # And assign changes from the for to this object.
871 # If the order is loaded from db, check if items are deleted in the form,
872 # remove them form the object and collect them for removing from db on saving.
873 # Then create/update items from form (via _make_item) and add them.
877 # add_items adds items to an order with no items for saving, but they cannot
878 # be retrieved via items until the order is saved. Adding empty items to new
879 # order here solves this problem.
881 $order = SL::DB::Manager::Order->find_by(id => $::form->{id}) if $::form->{id};
882 $order ||= SL::DB::Order->new(orderitems => []);
884 my $form_orderitems = delete $::form->{order}->{orderitems};
885 $order->assign_attributes(%{$::form->{order}});
887 # remove deleted items
888 $self->item_ids_to_delete([]);
889 foreach my $idx (reverse 0..$#{$order->orderitems}) {
890 my $item = $order->orderitems->[$idx];
891 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
892 splice @{$order->orderitems}, $idx, 1;
893 push @{$self->item_ids_to_delete}, $item->id;
899 foreach my $form_attr (@{$form_orderitems}) {
900 my $item = _make_item($order, $form_attr);
901 $item->position($pos);
905 $order->add_items(grep {!$_->id} @items);
910 # create or update items from form
912 # Make item objects from form values. For items already existing read from db.
913 # Create a new item else. And assign attributes.
915 my ($record, $attr) = @_;
918 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
922 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
923 # they cannot be retrieved via custom_variables until the order/orderitem is
924 # saved. Adding empty custom_variables to new orderitem here solves this problem.
925 $item ||= SL::DB::OrderItem->new(custom_variables => []);
927 $item->assign_attributes(%$attr);
928 $item->longdescription($item->part->notes) if $is_new && !defined $attr->{longdescription};
929 $item->project_id($record->globalproject_id) if $is_new && !defined $attr->{project_id};
930 $item->lastcost($item->part->lastcost) if $is_new && !defined $attr->{lastcost_as_number};
937 # This is used to add one item
939 my ($record, $attr) = @_;
941 my $item = SL::DB::OrderItem->new;
942 $item->assign_attributes(%$attr);
944 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
945 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
947 $item->unit($part->unit) if !$item->unit;
950 if ( $part->is_assortment ) {
951 # add assortment items with price 0, as the components carry the price
952 $price_src = $price_source->price_from_source("");
953 $price_src->price(0);
954 } elsif ($item->sellprice) {
955 $price_src = $price_source->price_from_source("");
956 $price_src->price($item->sellprice);
958 $price_src = $price_source->best_price
959 ? $price_source->best_price
960 : $price_source->price_from_source("");
961 $price_src->price(0) if !$price_source->best_price;
965 if ($item->discount) {
966 $discount_src = $price_source->discount_from_source("");
967 $discount_src->discount($item->discount);
969 $discount_src = $price_source->best_discount
970 ? $price_source->best_discount
971 : $price_source->discount_from_source("");
972 $discount_src->discount(0) if !$price_source->best_discount;
976 $new_attr{part} = $part;
977 $new_attr{description} = $part->description if ! $item->description;
978 $new_attr{qty} = 1.0 if ! $item->qty;
979 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
980 $new_attr{sellprice} = $price_src->price;
981 $new_attr{discount} = $discount_src->discount;
982 $new_attr{active_price_source} = $price_src;
983 $new_attr{active_discount_source} = $discount_src;
984 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
985 $new_attr{project_id} = $record->globalproject_id;
986 $new_attr{lastcost} = $part->lastcost;
988 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
989 # they cannot be retrieved via custom_variables until the order/orderitem is
990 # saved. Adding empty custom_variables to new orderitem here solves this problem.
991 $new_attr{custom_variables} = [];
993 $item->assign_attributes(%new_attr);
998 # recalculate prices and taxes
1000 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1004 # bb: todo: currency later
1005 $self->order->currency_id($::instance_conf->get_currency_id());
1007 my %pat = $self->order->calculate_prices_and_taxes();
1008 $self->{taxes} = [];
1009 foreach my $tax_chart_id (keys %{ $pat{taxes} }) {
1010 my $tax = SL::DB::Manager::Tax->find_by(chart_id => $tax_chart_id);
1012 my @amount_keys = grep { $pat{amounts}->{$_}->{tax_id} == $tax->id } keys %{ $pat{amounts} };
1013 push(@{ $self->{taxes} }, { amount => $pat{taxes}->{$tax_chart_id},
1014 netamount => $pat{amounts}->{$amount_keys[0]}->{amount},
1018 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items}, @{$pat{items}};
1021 # get data for saving, printing, ..., that is not changed in the form
1023 # Only cvars for now.
1024 sub _get_unalterable_data {
1027 foreach my $item (@{ $self->order->items }) {
1028 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1029 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1030 foreach my $var (@{ $item->cvars_by_config }) {
1031 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1033 $item->parse_custom_variable_values;
1039 # And remove related files in the spool directory
1044 my $db = $self->order->db;
1046 $db->with_transaction(
1048 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1049 $self->order->delete;
1050 my $spool = $::lx_office_conf{paths}->{spool};
1051 unlink map { "$spool/$_" } @spoolfiles if $spool;
1054 }) || push(@{$errors}, $db->error);
1061 # And delete items that are deleted in the form.
1066 my $db = $self->order->db;
1068 $db->with_transaction(sub {
1069 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete};
1070 $self->order->save(cascade => 1);
1071 }) || push(@{$errors}, $db->error);
1080 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1081 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1082 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1085 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1088 $self->{all_projects} = SL::DB::Manager::Project->get_all(where => [ or => [ id => $self->order->globalproject_id,
1090 sort_by => 'projectnumber');
1091 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1094 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1096 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1098 my $print_form = Form->new('');
1099 $print_form->{type} = $self->type;
1100 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1101 $print_form->{languages} = SL::DB::Manager::Language->get_all_sorted;
1102 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1103 form => $print_form,
1104 options => {dialog_name_prefix => 'print_options.',
1108 no_opendocument => 1,
1112 foreach my $item (@{$self->order->orderitems}) {
1113 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1114 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1115 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1118 if ($self->order->ordnumber && $::instance_conf->get_webdav) {
1119 my $webdav = SL::Webdav->new(
1120 type => $self->type,
1121 number => $self->order->ordnumber,
1123 my @all_objects = $webdav->get_all_objects;
1124 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1126 link => File::Spec->catfile($_->full_filedescriptor),
1130 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery);
1131 $self->_setup_edit_action_bar;
1134 sub _setup_edit_action_bar {
1135 my ($self, %params) = @_;
1137 my $deletion_allowed = (($self->cv eq 'customer') && $::instance_conf->get_sales_order_show_delete)
1138 || (($self->cv eq 'vendor') && $::instance_conf->get_purchase_order_show_delete);
1140 for my $bar ($::request->layout->get('actionbar')) {
1145 call => [ 'kivi.Order.save', $::instance_conf->get_order_warn_duplicate_parts ],
1146 accesskey => 'enter',
1149 t8('Save and Delivery Order'),
1150 call => [ 'kivi.Order.save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts ],
1153 t8('Save and Invoice'),
1154 call => [ 'kivi.Order.save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1157 ], # end of combobox "Save"
1165 call => [ 'kivi.Order.show_print_options' ],
1169 call => [ 'kivi.Order.email' ],
1172 t8('Download attachments of all parts'),
1173 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1174 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1175 only_if => $::instance_conf->get_doc_storage,
1177 ], # end of combobox "Export"
1181 call => [ 'kivi.Order.delete_order' ],
1182 confirm => $::locale->text('Do you really want to delete this object?'),
1183 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1184 only_if => $deletion_allowed,
1191 my ($order, $pdf_ref, $params) = @_;
1195 my $print_form = Form->new('');
1196 $print_form->{type} = $order->type;
1197 $print_form->{formname} = $params->{formname} || $order->type;
1198 $print_form->{format} = $params->{format} || 'pdf';
1199 $print_form->{media} = $params->{media} || 'file';
1200 $print_form->{groupitems} = $params->{groupitems};
1201 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1203 $order->language($params->{language});
1204 $order->flatten_to_form($print_form, format_amounts => 1);
1206 # search for the template
1207 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1208 name => $print_form->{formname},
1209 email => $print_form->{media} eq 'email',
1210 language => $params->{language},
1211 printer_id => $print_form->{printer_id}, # todo
1214 if (!defined $template_file) {
1215 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);
1218 return @errors if scalar @errors;
1220 $print_form->throw_on_error(sub {
1222 $print_form->prepare_for_printing;
1224 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1225 template => $template_file,
1226 variables => $print_form,
1227 variable_content_types => {
1228 longdescription => 'html',
1229 partnotes => 'html',
1234 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->getMessage : $EVAL_ERROR;
1240 sub _sales_order_type {
1244 sub _purchase_order_type {
1256 SL::Controller::Order - controller for orders
1260 This is a new form to enter orders, completely rewritten with the use
1261 of controller and java script techniques.
1263 The aim is to provide the user a better expirience and a faster flow
1264 of work. Also the code should be more readable, more reliable and
1273 One input row, so that input happens every time at the same place.
1277 Use of pickers where possible.
1281 Possibility to enter more than one item at once.
1285 Save order only on "save" (and "save and delivery order"-workflow). No
1286 hidden save on "print" or "email".
1290 Item list in a scrollable area, so that the workflow buttons stay at
1295 Reordering item rows with drag and drop is possible. Sorting item rows is
1296 possible (by partnumber, description, qty, sellprice and discount for now).
1300 No C<update> is necessary. All entries and calculations are managed
1301 with ajax-calls and the page does only reload on C<save>.
1305 User can see changes immediately, because of the use of java script
1316 =item * C<SL/Controller/Order.pm>
1320 =item * C<template/webpages/order/form.html>
1324 =item * C<template/webpages/order/tabs/basic_data.html>
1326 Main tab for basic_data.
1328 This is the only tab here for now. "linked records" and "webdav" tabs are
1329 reused from generic code.
1333 =item * C<template/webpages/order/tabs/_item_input.html>
1335 The input line for items
1337 =item * C<template/webpages/order/tabs/_row.html>
1339 One row for already entered items
1341 =item * C<template/webpages/order/tabs/_tax_row.html>
1343 Displaying tax information
1345 =item * C<template/webpages/order/tabs/_multi_items_dialog.html>
1347 Dialog for entering more than one item at once
1349 =item * C<template/webpages/order/tabs/_multi_items_result.html>
1351 Results for the filter in the multi items dialog
1353 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
1355 Dialog for selecting price and discount sources
1357 =item * C<template/webpages/order/tabs/_email_dialog.html>
1363 =item * C<js/kivi.Order.js>
1365 java script functions
1377 =item * customer/vendor details ('D'-button)
1379 =item * credit limit
1381 =item * more workflows (save as new, quotation, purchase order)
1383 =item * price sources: little symbols showing better price / better discount
1385 =item * select units in input row?
1387 =item * custom shipto address
1389 =item * periodic invoices
1391 =item * language / part translations
1393 =item * access rights
1395 =item * display weights
1401 =item * optional client/user behaviour
1403 (transactions has to be set - department has to be set -
1404 force project if enabled in client config - transport cost reminder)
1408 =head1 KNOWN BUGS AND CAVEATS
1414 Customer discount is not displayed as a valid discount in price source popup
1415 (this might be a bug in price sources)
1417 (I cannot reproduce this (Bernd))
1421 No indication that <shift>-up/down expands/collapses second row.
1425 Inline creation of parts is not currently supported
1429 Table header is not sticky in the scrolling area.
1433 Sorting does not include C<position>, neither does reordering.
1435 This behavior was implemented intentionally. But we can discuss, which behavior
1436 should be implemented.
1440 C<show_multi_items_dialog> does not use the currently inserted string for
1445 =head1 To discuss / Nice to have
1451 How to expand/collapse second row. Now it can be done clicking the icon or
1456 Possibility to change longdescription in input row?
1460 Possibility to select PriceSources in input row?
1464 This controller uses a (changed) copy of the template for the PriceSource
1465 dialog. Maybe there could be used one code source.
1469 Rounding-differences between this controller (PriceTaxCalculator) and the old
1470 form. This is not only a problem here, but also in all parts using the PTC.
1471 There exists a ticket and a patch. This patch should be testet.
1475 An indicator, if the actual inputs are saved (like in an
1476 editor or on text processing application).
1480 A warning when leaving the page without saveing unchanged inputs.
1484 Workflows for delivery order and invoice are in the menu "Save", because the
1485 order is saved before opening the new document form. Nevertheless perhaps these
1486 workflow buttons should be put under "Workflows".
1493 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>