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 print create_pdf send_email) ]);
44 __PACKAGE__->run_before('_get_unalterable_data',
45 only => [ qw(save save_and_delivery_order 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 my $form = Form->new;
156 $form->{ordnumber} = $self->order->ordnumber;
157 $form->{type} = $self->type;
158 $form->{format} = $format;
159 $form->{formname} = $formname;
160 $form->{language} = '_' . $language->template_code if $language;
161 my $pdf_filename = $form->generate_attachment_filename();
164 my @errors = _create_pdf($self->order, \$pdf, { format => $format,
165 formname => $formname,
166 language => $language,
167 groupitems => $groupitems });
168 if (scalar @errors) {
169 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
172 if ($media eq 'screen') {
174 my $sfile = SL::SessionFile::Random->new(mode => "w");
175 $sfile->fh->print($pdf);
178 my $key = join('_', Time::HiRes::gettimeofday(), int rand 1000000000000);
179 $::auth->set_session_value("Order::create_pdf-${key}" => $sfile->file_name);
182 ->run('kivi.Order.download_pdf', $pdf_filename, $key)
183 ->flash('info', t8('The PDF has been created'));
185 } elsif ($media eq 'printer') {
187 my $printer_id = $::form->{print_options}->{printer_id};
188 SL::DB::Printer->new(id => $printer_id)->load->print_document(
193 $self->js->flash('info', t8('The PDF has been printed'));
196 # copy file to webdav folder
197 if ($self->order->ordnumber && $::instance_conf->get_webdav_documents) {
198 my $webdav = SL::Webdav->new(
200 number => $self->order->ordnumber,
202 my $webdav_file = SL::Webdav::File->new(
204 filename => $pdf_filename,
207 $webdav_file->store(data => \$pdf);
210 $self->js->flash('error', t8('Storing PDF to webdav folder failed: #1', $@));
213 if ($self->order->ordnumber && $::instance_conf->get_doc_storage) {
215 SL::File->save(object_id => $self->order->id,
216 object_type => $self->type,
217 mime_type => 'application/pdf',
219 file_type => 'document',
220 file_name => $pdf_filename,
221 file_contents => $pdf);
224 $self->js->flash('error', t8('Storing PDF in storage backend failed: #1', $@));
230 # offer pdf for download
232 # It needs to get the key for the session value to get the pdf file.
233 sub action_download_pdf {
236 my $key = $::form->{key};
237 my $tmp_filename = $::auth->get_session_value("Order::create_pdf-${key}");
238 return $self->send_file(
240 type => 'application/pdf',
241 name => $::form->{pdf_filename},
245 # open the email dialog
246 sub action_show_email_dialog {
249 my $cv_method = $self->cv;
251 if (!$self->order->$cv_method) {
252 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'))
256 $self->{email}->{to} = $self->order->contact->cp_email if $self->order->contact;
257 $self->{email}->{to} ||= $self->order->$cv_method->email;
258 $self->{email}->{cc} = $self->order->$cv_method->cc;
259 $self->{email}->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
260 # Todo: get addresses from shipto, if any
262 my $form = Form->new;
263 $form->{ordnumber} = $self->order->ordnumber;
264 $form->{formname} = $self->type;
265 $form->{type} = $self->type;
266 $form->{language} = 'de';
267 $form->{format} = 'pdf';
269 $self->{email}->{subject} = $form->generate_email_subject();
270 $self->{email}->{attachment_filename} = $form->generate_attachment_filename();
271 $self->{email}->{message} = $form->create_email_signature();
273 my $dialog_html = $self->render('order/tabs/_email_dialog', { output => 0 });
275 ->run('kivi.Order.show_email_dialog', $dialog_html)
282 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
283 sub action_send_email {
286 my $mail = Mailer->new;
287 $mail->{from} = qq|"$::myconfig{name}" <$::myconfig{email}>|;
288 $mail->{$_} = $::form->{email}->{$_} for qw(to cc bcc subject message);
291 my @errors = _create_pdf($self->order, \$pdf, {media => 'email'});
292 if (scalar @errors) {
293 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
296 $mail->{attachments} = [{ "content" => $pdf,
297 "name" => $::form->{email}->{attachment_filename} }];
299 if (my $err = $mail->send) {
300 return $self->js->flash('error', t8('Sending E-mail: ') . $err)
305 my $intnotes = $self->order->intnotes;
306 $intnotes .= "\n\n" if $self->order->intnotes;
307 $intnotes .= t8('[email]') . "\n";
308 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
309 $intnotes .= t8('To (email)') . ": " . $mail->{to} . "\n";
310 $intnotes .= t8('Cc') . ": " . $mail->{cc} . "\n" if $mail->{cc};
311 $intnotes .= t8('Bcc') . ": " . $mail->{bcc} . "\n" if $mail->{bcc};
312 $intnotes .= t8('Subject') . ": " . $mail->{subject} . "\n\n";
313 $intnotes .= t8('Message') . ": " . $mail->{message};
316 ->val('#order_intnotes', $intnotes)
317 ->run('kivi.Order.close_email_dialog')
321 # save the order and redirect to the frontend subroutine for a new
323 sub action_save_and_delivery_order {
326 my $errors = $self->_save();
328 if (scalar @{ $errors }) {
329 $self->js->flash('error', $_) foreach @{ $errors };
330 return $self->js->render();
332 flash_later('info', $::locale->text('The order has been saved'));
334 my @redirect_params = (
335 controller => 'oe.pl',
336 action => 'oe_delivery_order_from_order',
337 id => $self->order->id,
340 $self->redirect_to(@redirect_params);
343 # set form elements in respect to a changed customer or vendor
345 # This action is called on an change of the customer/vendor picker.
346 sub action_customer_vendor_changed {
349 my $cv_method = $self->cv;
351 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
352 $self->js->show('#cp_row');
354 $self->js->hide('#cp_row');
357 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
358 $self->js->show('#shipto_row');
360 $self->js->hide('#shipto_row');
363 $self->order->taxzone_id($self->order->$cv_method->taxzone_id);
365 if ($self->order->is_sales) {
366 $self->order->taxincluded(defined($self->order->$cv_method->taxincluded_checked)
367 ? $self->order->$cv_method->taxincluded_checked
368 : $::myconfig{taxincluded_checked});
371 $self->order->payment_id($self->order->$cv_method->payment_id);
372 $self->order->delivery_term_id($self->order->$cv_method->delivery_term_id);
377 ->replaceWith('#order_cp_id', $self->build_contact_select)
378 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
379 ->val( '#order_taxzone_id', $self->order->taxzone_id)
380 ->val( '#order_taxincluded', $self->order->taxincluded)
381 ->val( '#order_payment_id', $self->order->payment_id)
382 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
383 ->val( '#order_intnotes', $self->order->$cv_method->notes)
384 ->focus( '#order_' . $self->cv . '_id');
386 $self->_js_redisplay_amounts_and_taxes;
390 # called if a unit in an existing item row is changed
391 sub action_unit_changed {
394 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
395 my $item = $self->order->items_sorted->[$idx];
397 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
398 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
403 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
404 $self->_js_redisplay_line_values;
405 $self->_js_redisplay_amounts_and_taxes;
409 # add an item row for a new item entered in the input row
410 sub action_add_item {
413 my $form_attr = $::form->{add_item};
415 return unless $form_attr->{parts_id};
417 my $item = _new_item($self->order, $form_attr);
419 $self->order->add_items($item);
423 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
424 my $row_as_html = $self->p->render('order/tabs/_row',
427 ALL_PRICE_FACTORS => $self->all_price_factors
431 ->append('#row_table_id', $row_as_html);
433 if ( $item->part->is_assortment ) {
434 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
435 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
436 my $attr = { parts_id => $assortment_item->parts_id,
437 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
438 unit => $assortment_item->unit,
439 description => $assortment_item->part->description,
441 my $item = _new_item($self->order, $attr);
443 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
444 $item->discount(1) unless $assortment_item->charge;
446 $self->order->add_items( $item );
448 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
449 my $row_as_html = $self->p->render('order/tabs/_row',
452 ALL_PRICE_FACTORS => $self->all_price_factors
455 ->append('#row_table_id', $row_as_html);
460 ->val('.add_item_input', '')
461 ->run('kivi.Order.init_row_handlers')
462 ->run('kivi.Order.row_table_scroll_down')
463 ->run('kivi.Order.renumber_positions')
464 ->focus('#add_item_parts_id_name');
466 $self->_js_redisplay_amounts_and_taxes;
470 # open the dialog for entering multiple items at once
471 sub action_show_multi_items_dialog {
472 require SL::DB::PartsGroup;
473 $_[0]->render('order/tabs/_multi_items_dialog', { layout => 0 },
474 all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
477 # update the filter results in the multi item dialog
478 sub action_multi_items_update_result {
481 $::form->{multi_items}->{filter}->{obsolete} = 0;
483 my $count = $_[0]->multi_items_models->count;
486 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
487 $_[0]->render($text, { layout => 0 });
488 } elsif ($count > $max_count) {
489 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
490 $_[0]->render($text, { layout => 0 });
492 my $multi_items = $_[0]->multi_items_models->get;
493 $_[0]->render('order/tabs/_multi_items_result', { layout => 0 },
494 multi_items => $multi_items);
498 # add item rows for multiple items at once
499 sub action_add_multi_items {
502 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_multi_items} };
503 return $self->js->render() unless scalar @form_attr;
506 foreach my $attr (@form_attr) {
507 my $item = _new_item($self->order, $attr);
509 if ( $item->part->is_assortment ) {
510 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
511 my $attr = { parts_id => $assortment_item->parts_id,
512 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
513 unit => $assortment_item->unit,
514 description => $assortment_item->part->description,
516 my $item = _new_item($self->order, $attr);
518 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
519 $item->discount(1) unless $assortment_item->charge;
520 push @items, $assortment_item;
524 $self->order->add_items(@items);
528 foreach my $item (@items) {
529 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
530 my $row_as_html = $self->p->render('order/tabs/_row',
533 ALL_PRICE_FACTORS => $self->all_price_factors
536 $self->js->append('#row_table_id', $row_as_html);
540 ->run('kivi.Order.close_multi_items_dialog')
541 ->run('kivi.Order.init_row_handlers')
542 ->run('kivi.Order.row_table_scroll_down')
543 ->run('kivi.Order.renumber_positions')
544 ->focus('#add_item_parts_id_name');
546 $self->_js_redisplay_amounts_and_taxes;
550 # recalculate all linetotals, amounts and taxes and redisplay them
551 sub action_recalc_amounts_and_taxes {
556 $self->_js_redisplay_line_values;
557 $self->_js_redisplay_amounts_and_taxes;
561 # redisplay item rows if they are sorted by an attribute
562 sub action_reorder_items {
566 partnumber => sub { $_[0]->part->partnumber },
567 description => sub { $_[0]->description },
568 qty => sub { $_[0]->qty },
569 sellprice => sub { $_[0]->sellprice },
570 discount => sub { $_[0]->discount },
573 my $method = $sort_keys{$::form->{order_by}};
574 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
575 if ($::form->{sort_dir}) {
576 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
578 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
581 ->run('kivi.Order.redisplay_items', \@to_sort)
585 # show the popup to choose a price/discount source
586 sub action_price_popup {
589 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
590 my $item = $self->order->items_sorted->[$idx];
592 $self->render_price_dialog($item);
595 # get the longdescription for an item if the dialog to enter/change the
596 # longdescription was opened and the longdescription is empty
598 # If this item is new, get the longdescription from Part.
599 # Otherwise get it from OrderItem.
600 sub action_get_item_longdescription {
603 if ($::form->{item_id}) {
604 $longdescription = SL::DB::OrderItem->new(id => $::form->{item_id})->load->longdescription;
605 } elsif ($::form->{parts_id}) {
606 $longdescription = SL::DB::Part->new(id => $::form->{parts_id})->load->notes;
608 $_[0]->render(\ $longdescription, { type => 'text' });
611 # load the second row for one or more items
613 # This action gets the html code for all items second rows by rendering a template for
614 # the second row and sets the html code via client js.
615 sub action_load_second_rows {
618 $self->_recalc() if $self->order->is_sales; # for margin calculation
620 foreach my $item_id (@{ $::form->{item_ids} }) {
621 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
622 my $item = $self->order->items_sorted->[$idx];
624 $self->_js_load_second_row($item, $item_id, 0);
627 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
632 sub _js_load_second_row {
633 my ($self, $item, $item_id, $do_parse) = @_;
636 # Parse values from form (they are formated while rendering (template)).
637 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
638 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
639 foreach my $var (@{ $item->cvars_by_config }) {
640 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
642 $item->parse_custom_variable_values;
645 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item);
648 ->html('.row_entry:has(#item_' . $item_id . ') [name = "second_row"]', $row_as_html)
649 ->data('.row_entry:has(#item_' . $item_id . ') [name = "second_row"]', 'loaded', 1);
652 sub _js_redisplay_line_values {
655 my $is_sales = $self->order->is_sales;
657 # sales orders with margins
662 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
663 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
664 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
665 ]} @{ $self->order->items_sorted };
669 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
670 ]} @{ $self->order->items_sorted };
674 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
677 sub _js_redisplay_amounts_and_taxes {
680 if (scalar @{ $self->{taxes} }) {
681 $self->js->show('#taxincluded_row_id');
683 $self->js->hide('#taxincluded_row_id');
686 if ($self->order->taxincluded) {
687 $self->js->hide('#subtotal_row_id');
689 $self->js->show('#subtotal_row_id');
693 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
694 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
696 ->insertBefore($self->build_tax_rows, '#amount_row_id');
703 sub init_valid_types {
704 [ _sales_order_type(), _purchase_order_type() ];
710 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
711 die "Not a valid type for order";
714 $self->type($::form->{type});
720 my $cv = $self->type eq _sales_order_type() ? 'customer'
721 : $self->type eq _purchase_order_type() ? 'vendor'
722 : die "Not a valid type for order";
735 # model used to filter/display the parts in the multi-items dialog
736 sub init_multi_items_models {
737 SL::Controller::Helper::GetModels->new(
740 with_objects => [ qw(unit_obj) ],
741 disable_plugin => 'paginated',
742 source => $::form->{multi_items},
748 partnumber => t8('Partnumber'),
749 description => t8('Description')}
753 sub init_all_price_factors {
754 SL::DB::Manager::PriceFactor->get_all;
760 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
762 my $right = $right_for->{ $self->type };
763 $right ||= 'DOES_NOT_EXIST';
765 $::auth->assert($right);
768 # build the selection box for contacts
770 # Needed, if customer/vendor changed.
771 sub build_contact_select {
774 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
775 value_key => 'cp_id',
776 title_key => 'full_name_dep',
777 default => $self->order->cp_id,
779 style => 'width: 300px',
783 # build the selection box for shiptos
785 # Needed, if customer/vendor changed.
786 sub build_shipto_select {
789 select_tag('order.shipto_id', [ $self->order->{$self->cv}->shipto ],
790 value_key => 'shipto_id',
791 title_key => 'displayable_id',
792 default => $self->order->shipto_id,
794 style => 'width: 300px',
798 # build the rows for displaying taxes
800 # Called if amounts where recalculated and redisplayed.
805 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
806 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
808 return $rows_as_html;
812 sub render_price_dialog {
813 my ($self, $record_item) = @_;
815 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
819 'kivi.io.price_chooser_dialog',
820 t8('Available Prices'),
821 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
826 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
827 # $self->js->show('#dialog_flash_error');
836 return if !$::form->{id};
838 $self->order(SL::DB::Manager::Order->find_by(id => $::form->{id}));
841 # load or create a new order object
843 # And assign changes from the for to this object.
844 # If the order is loaded from db, check if items are deleted in the form,
845 # remove them form the object and collect them for removing from db on saving.
846 # Then create/update items from form (via _make_item) and add them.
850 # add_items adds items to an order with no items for saving, but they cannot
851 # be retrieved via items until the order is saved. Adding empty items to new
852 # order here solves this problem.
854 $order = SL::DB::Manager::Order->find_by(id => $::form->{id}) if $::form->{id};
855 $order ||= SL::DB::Order->new(orderitems => []);
857 my $form_orderitems = delete $::form->{order}->{orderitems};
858 $order->assign_attributes(%{$::form->{order}});
860 # remove deleted items
861 $self->item_ids_to_delete([]);
862 foreach my $idx (reverse 0..$#{$order->orderitems}) {
863 my $item = $order->orderitems->[$idx];
864 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
865 splice @{$order->orderitems}, $idx, 1;
866 push @{$self->item_ids_to_delete}, $item->id;
872 foreach my $form_attr (@{$form_orderitems}) {
873 my $item = _make_item($order, $form_attr);
874 $item->position($pos);
878 $order->add_items(grep {!$_->id} @items);
883 # create or update items from form
885 # Make item objects from form values. For items already existing read from db.
886 # Create a new item else. And assign attributes.
888 my ($record, $attr) = @_;
891 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
895 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
896 # they cannot be retrieved via custom_variables until the order/orderitem is
897 # saved. Adding empty custom_variables to new orderitem here solves this problem.
898 $item ||= SL::DB::OrderItem->new(custom_variables => []);
900 $item->assign_attributes(%$attr);
901 $item->longdescription($item->part->notes) if $is_new && !defined $attr->{longdescription};
902 $item->project_id($record->globalproject_id) if $is_new && !defined $attr->{project_id};
903 $item->lastcost($item->part->lastcost) if $is_new && !defined $attr->{lastcost_as_number};
910 # This is used to add one item
912 my ($record, $attr) = @_;
914 my $item = SL::DB::OrderItem->new;
915 $item->assign_attributes(%$attr);
917 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
918 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
920 $item->unit($part->unit) if !$item->unit;
923 if ( $part->is_assortment ) {
924 # add assortment items with price 0, as the components carry the price
925 $price_src = $price_source->price_from_source("");
926 $price_src->price(0);
927 } elsif ($item->sellprice) {
928 $price_src = $price_source->price_from_source("");
929 $price_src->price($item->sellprice);
931 $price_src = $price_source->best_price
932 ? $price_source->best_price
933 : $price_source->price_from_source("");
934 $price_src->price(0) if !$price_source->best_price;
938 if ($item->discount) {
939 $discount_src = $price_source->discount_from_source("");
940 $discount_src->discount($item->discount);
942 $discount_src = $price_source->best_discount
943 ? $price_source->best_discount
944 : $price_source->discount_from_source("");
945 $discount_src->discount(0) if !$price_source->best_discount;
949 $new_attr{part} = $part;
950 $new_attr{description} = $part->description if ! $item->description;
951 $new_attr{qty} = 1.0 if ! $item->qty;
952 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
953 $new_attr{sellprice} = $price_src->price;
954 $new_attr{discount} = $discount_src->discount;
955 $new_attr{active_price_source} = $price_src;
956 $new_attr{active_discount_source} = $discount_src;
957 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
958 $new_attr{project_id} = $record->globalproject_id;
959 $new_attr{lastcost} = $part->lastcost;
961 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
962 # they cannot be retrieved via custom_variables until the order/orderitem is
963 # saved. Adding empty custom_variables to new orderitem here solves this problem.
964 $new_attr{custom_variables} = [];
966 $item->assign_attributes(%new_attr);
971 # recalculate prices and taxes
973 # Using the PriceTaxCalculator. Store linetotals in the item objects.
977 # bb: todo: currency later
978 $self->order->currency_id($::instance_conf->get_currency_id());
980 my %pat = $self->order->calculate_prices_and_taxes();
982 foreach my $tax_chart_id (keys %{ $pat{taxes} }) {
983 my $tax = SL::DB::Manager::Tax->find_by(chart_id => $tax_chart_id);
985 my @amount_keys = grep { $pat{amounts}->{$_}->{tax_id} == $tax->id } keys %{ $pat{amounts} };
986 push(@{ $self->{taxes} }, { amount => $pat{taxes}->{$tax_chart_id},
987 netamount => $pat{amounts}->{$amount_keys[0]}->{amount},
991 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items}, @{$pat{items}};
994 # get data for saving, printing, ..., that is not changed in the form
996 # Only cvars for now.
997 sub _get_unalterable_data {
1000 foreach my $item (@{ $self->order->items }) {
1001 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1002 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1003 foreach my $var (@{ $item->cvars_by_config }) {
1004 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1006 $item->parse_custom_variable_values;
1012 # And remove related files in the spool directory
1017 my $db = $self->order->db;
1019 $db->with_transaction(
1021 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1022 $self->order->delete;
1023 my $spool = $::lx_office_conf{paths}->{spool};
1024 unlink map { "$spool/$_" } @spoolfiles if $spool;
1027 }) || push(@{$errors}, $db->error);
1034 # And delete items that are deleted in the form.
1039 my $db = $self->order->db;
1041 $db->with_transaction(sub {
1042 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete};
1043 $self->order->save(cascade => 1);
1044 }) || push(@{$errors}, $db->error);
1053 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1054 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1055 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1058 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1061 $self->{all_projects} = SL::DB::Manager::Project->get_all(where => [ or => [ id => $self->order->globalproject_id,
1063 sort_by => 'projectnumber');
1064 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1067 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1069 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1071 my $print_form = Form->new('');
1072 $print_form->{type} = $self->type;
1073 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1074 $print_form->{languages} = SL::DB::Manager::Language->get_all_sorted;
1075 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1076 form => $print_form,
1077 options => {dialog_name_prefix => 'print_options.',
1081 no_opendocument => 1,
1085 foreach my $item (@{$self->order->orderitems}) {
1086 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1087 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1088 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1091 if ($self->order->ordnumber && $::instance_conf->get_webdav) {
1092 my $webdav = SL::Webdav->new(
1093 type => $self->type,
1094 number => $self->order->ordnumber,
1096 my @all_objects = $webdav->get_all_objects;
1097 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1099 link => File::Spec->catfile($_->full_filedescriptor),
1103 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery);
1104 $self->_setup_edit_action_bar;
1107 sub _setup_edit_action_bar {
1108 my ($self, %params) = @_;
1110 my $deletion_allowed = (($self->cv eq 'customer') && $::instance_conf->get_sales_order_show_delete)
1111 || (($self->cv eq 'vendor') && $::instance_conf->get_purchase_order_show_delete);
1113 for my $bar ($::request->layout->get('actionbar')) {
1118 call => [ 'kivi.Order.save', $::instance_conf->get_order_warn_duplicate_parts ],
1119 accesskey => 'enter',
1122 t8('Save and Delivery Order'),
1123 call => [ 'kivi.Order.save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts ],
1126 ], # end of combobox "Save"
1134 call => [ 'kivi.Order.show_print_options' ],
1138 call => [ 'kivi.Order.email' ],
1141 t8('Download attachments of all parts'),
1142 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1143 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1144 only_if => $::instance_conf->get_doc_storage,
1146 ], # end of combobox "Export"
1150 call => [ 'kivi.Order.delete_order' ],
1151 confirm => $::locale->text('Do you really want to delete this object?'),
1152 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1153 only_if => $deletion_allowed,
1160 my ($order, $pdf_ref, $params) = @_;
1164 my $print_form = Form->new('');
1165 $print_form->{type} = $order->type;
1166 $print_form->{formname} = $params->{formname} || $order->type;
1167 $print_form->{format} = $params->{format} || 'pdf';
1168 $print_form->{media} = $params->{media} || 'file';
1169 $print_form->{groupitems} = $params->{groupitems};
1170 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1171 $print_form->{language} = $params->{language}->template_code if $print_form->{language};
1172 $print_form->{language_id} = $params->{language}->id if $print_form->{language};
1174 $order->flatten_to_form($print_form, format_amounts => 1);
1176 # search for the template
1177 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1178 name => $print_form->{formname},
1179 email => $print_form->{media} eq 'email',
1180 language => $params->{language},
1181 printer_id => $print_form->{printer_id}, # todo
1184 if (!defined $template_file) {
1185 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);
1188 return @errors if scalar @errors;
1190 $print_form->throw_on_error(sub {
1192 $print_form->prepare_for_printing;
1194 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1195 template => $template_file,
1196 variables => $print_form,
1197 variable_content_types => {
1198 longdescription => 'html',
1199 partnotes => 'html',
1204 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->getMessage : $EVAL_ERROR;
1210 sub _sales_order_type {
1214 sub _purchase_order_type {
1226 SL::Controller::Order - controller for orders
1230 This is a new form to enter orders, completely rewritten with the use
1231 of controller and java script techniques.
1233 The aim is to provide the user a better expirience and a faster flow
1234 of work. Also the code should be more readable, more reliable and
1243 One input row, so that input happens every time at the same place.
1247 Use of pickers where possible.
1251 Possibility to enter more than one item at once.
1255 Save order only on "save" (and "save and delivery order"-workflow). No
1256 hidden save on "print" or "email".
1260 Item list in a scrollable area, so that the workflow buttons stay at
1265 Reordering item rows with drag and drop is possible. Sorting item rows is
1266 possible (by partnumber, description, qty, sellprice and discount for now).
1270 No C<update> is necessary. All entries and calculations are managed
1271 with ajax-calls and the page does only reload on C<save>.
1275 User can see changes immediately, because of the use of java script
1286 =item * C<SL/Controller/Order.pm>
1290 =item * C<template/webpages/order/form.html>
1294 =item * C<template/webpages/order/tabs/basic_data.html>
1296 Main tab for basic_data.
1298 This is the only tab here for now. "linked records" and "webdav" tabs are
1299 reused from generic code.
1303 =item * C<template/webpages/order/tabs/_item_input.html>
1305 The input line for items
1307 =item * C<template/webpages/order/tabs/_row.html>
1309 One row for already entered items
1311 =item * C<template/webpages/order/tabs/_tax_row.html>
1313 Displaying tax information
1315 =item * C<template/webpages/order/tabs/_multi_items_dialog.html>
1317 Dialog for entering more than one item at once
1319 =item * C<template/webpages/order/tabs/_multi_items_result.html>
1321 Results for the filter in the multi items dialog
1323 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
1325 Dialog for selecting price and discount sources
1327 =item * C<template/webpages/order/tabs/_email_dialog.html>
1333 =item * C<js/kivi.Order.js>
1335 java script functions
1347 =item * customer/vendor details ('D'-button)
1349 =item * credit limit
1351 =item * more workflows (save as new / invoice)
1353 =item * price sources: little symbols showing better price / better discount
1355 =item * select units in input row?
1357 =item * custom shipto address
1359 =item * periodic invoices
1361 =item * language / part translations
1363 =item * access rights
1365 =item * preset salesman from customer
1367 =item * display weights
1373 =item * optional client/user behaviour
1375 (transactions has to be set - department has to be set -
1376 force project if enabled in client config - transport cost reminder)
1380 =head1 KNOWN BUGS AND CAVEATS
1386 Customer discount is not displayed as a valid discount in price source popup
1387 (this might be a bug in price sources)
1391 No indication that <shift>-up/down expands/collapses second row.
1395 Inline creation of parts is not currently supported
1399 Table header is not sticky in the scrolling area.
1403 Sorting does not include C<position>, neither does reordering.
1405 This behavior was implemented intentionally. But we can discuss, which behavior
1406 should be implemented.
1410 C<show_multi_items_dialog> does not use the currently inserted string for
1415 =head1 To discuss / Nice to have
1421 How to expand/collapse second row. Now it can be done clicking the icon or
1426 Possibility to change longdescription in input row?
1430 Possibility to select PriceSources in input row?
1434 This controller uses a (changed) copy of the template for the PriceSource
1435 dialog. Maybe there could be used one code source.
1439 Rounding-differences between this controller (PriceTaxCalculator) and the old
1440 form. This is not only a problem here, but also in all parts using the PTC.
1441 There exists a ticket and a patch. This patch should be testet.
1445 An indicator, if the actual inputs are saved (like in an
1446 editor or on text processing application).
1450 A warning when leaving the page without saveing unchanged inputs.
1456 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>