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::UtilsBy qw(sort_by uniq_by);
28 use List::MoreUtils qw(none pairwise first_index);
29 use English qw(-no_match_vars);
33 use Rose::Object::MakeMethods::Generic
35 scalar => [ qw(item_ids_to_delete) ],
36 'scalar --get_set_init' => [ qw(order valid_types type cv p multi_items_models all_price_factors) ],
41 __PACKAGE__->run_before('_check_auth');
43 __PACKAGE__->run_before('_recalc',
44 only => [ qw(save save_and_delivery_order save_and_invoice print create_pdf send_email) ]);
46 __PACKAGE__->run_before('_get_unalterable_data',
47 only => [ qw(save save_and_delivery_order save_and_invoice print create_pdf send_email) ]);
57 $self->order->transdate(DateTime->now_local());
58 $self->order->reqdate(DateTime->today_local->next_workday) if !$self->order->reqdate;
63 title => $self->type eq _sales_order_type() ? $::locale->text('Add Sales Order')
64 : $self->type eq _purchase_order_type() ? $::locale->text('Add Purchase Order')
66 %{$self->{template_args}}
70 # edit an existing order
79 title => $self->type eq _sales_order_type() ? $::locale->text('Edit Sales Order')
80 : $self->type eq _purchase_order_type() ? $::locale->text('Edit Purchase Order')
82 %{$self->{template_args}}
90 my $errors = $self->_delete();
92 if (scalar @{ $errors }) {
93 $self->js->flash('error', $_) foreach @{ $errors };
94 return $self->js->render();
97 flash_later('info', $::locale->text('The order has been deleted'));
98 my @redirect_params = (
103 $self->redirect_to(@redirect_params);
110 my $errors = $self->_save();
112 if (scalar @{ $errors }) {
113 $self->js->flash('error', $_) foreach @{ $errors };
114 return $self->js->render();
117 flash_later('info', $::locale->text('The order has been saved'));
118 my @redirect_params = (
121 id => $self->order->id,
124 $self->redirect_to(@redirect_params);
129 # This is called if "print" is pressed in the print dialog.
130 # If PDF creation was requested and succeeded, the pdf is stored in a session
131 # file and the filename is stored as session value with an unique key. A
132 # javascript function with this key is then called. This function calls the
133 # download action below (action_download_pdf), which offers the file for
138 my $format = $::form->{print_options}->{format};
139 my $media = $::form->{print_options}->{media};
140 my $formname = $::form->{print_options}->{formname};
141 my $copies = $::form->{print_options}->{copies};
142 my $groupitems = $::form->{print_options}->{groupitems};
145 if (none { $format eq $_ } qw(pdf)) {
146 return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
149 # only screen or printer by now
150 if (none { $media eq $_ } qw(screen printer)) {
151 return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
155 $language = SL::DB::Language->new(id => $::form->{print_options}->{language_id})->load if $::form->{print_options}->{language_id};
157 # create a form for generate_attachment_filename
158 my $form = Form->new;
159 $form->{ordnumber} = $self->order->ordnumber;
160 $form->{type} = $self->type;
161 $form->{format} = $format;
162 $form->{formname} = $formname;
163 $form->{language} = '_' . $language->template_code if $language;
164 my $pdf_filename = $form->generate_attachment_filename();
167 my @errors = _create_pdf($self->order, \$pdf, { format => $format,
168 formname => $formname,
169 language => $language,
170 groupitems => $groupitems });
171 if (scalar @errors) {
172 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
175 if ($media eq 'screen') {
177 my $sfile = SL::SessionFile::Random->new(mode => "w");
178 $sfile->fh->print($pdf);
181 my $key = join('_', Time::HiRes::gettimeofday(), int rand 1000000000000);
182 $::auth->set_session_value("Order::create_pdf-${key}" => $sfile->file_name);
185 ->run('kivi.Order.download_pdf', $pdf_filename, $key)
186 ->flash('info', t8('The PDF has been created'));
188 } elsif ($media eq 'printer') {
190 my $printer_id = $::form->{print_options}->{printer_id};
191 SL::DB::Printer->new(id => $printer_id)->load->print_document(
196 $self->js->flash('info', t8('The PDF has been printed'));
199 # copy file to webdav folder
200 if ($self->order->ordnumber && $::instance_conf->get_webdav_documents) {
201 my $webdav = SL::Webdav->new(
203 number => $self->order->ordnumber,
205 my $webdav_file = SL::Webdav::File->new(
207 filename => $pdf_filename,
210 $webdav_file->store(data => \$pdf);
213 $self->js->flash('error', t8('Storing PDF to webdav folder failed: #1', $@));
216 if ($self->order->ordnumber && $::instance_conf->get_doc_storage) {
218 SL::File->save(object_id => $self->order->id,
219 object_type => $self->type,
220 mime_type => 'application/pdf',
222 file_type => 'document',
223 file_name => $pdf_filename,
224 file_contents => $pdf);
227 $self->js->flash('error', t8('Storing PDF in storage backend failed: #1', $@));
233 # offer pdf for download
235 # It needs to get the key for the session value to get the pdf file.
236 sub action_download_pdf {
239 my $key = $::form->{key};
240 my $tmp_filename = $::auth->get_session_value("Order::create_pdf-${key}");
241 return $self->send_file(
243 type => 'application/pdf',
244 name => $::form->{pdf_filename},
248 # open the email dialog
249 sub action_show_email_dialog {
252 my $cv_method = $self->cv;
254 if (!$self->order->$cv_method) {
255 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'))
260 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
261 $email_form->{to} ||= $self->order->$cv_method->email;
262 $email_form->{cc} = $self->order->$cv_method->cc;
263 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
264 # Todo: get addresses from shipto, if any
266 my $form = Form->new;
267 $form->{ordnumber} = $self->order->ordnumber;
268 $form->{formname} = $self->type;
269 $form->{type} = $self->type;
270 $form->{language} = 'de';
271 $form->{format} = 'pdf';
273 $email_form->{subject} = $form->generate_email_subject();
274 $email_form->{attachment_filename} = $form->generate_attachment_filename();
275 $email_form->{message} = $form->generate_email_body();
276 $email_form->{js_send_function} = 'kivi.Order.send_email()';
278 my %files = $self->_get_files_for_email_dialog();
279 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
280 email_form => $email_form,
281 show_bcc => $::auth->assert('email_bcc', 'may fail'),
283 is_customer => $self->cv eq 'customer',
287 ->run('kivi.Order.show_email_dialog', $dialog_html)
294 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
295 sub action_send_email {
298 my $email_form = delete $::form->{email_form};
299 my %field_names = (to => 'email');
301 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
303 # for Form::cleanup which may be called in Form::send_email
304 $::form->{cwd} = getcwd();
305 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
307 $::form->{media} = 'email';
309 if (($::form->{attachment_policy} // '') eq 'normal') {
311 $language = SL::DB::Language->new(id => $::form->{print_options}->{language_id})->load if $::form->{print_options}->{language_id};
314 my @errors = _create_pdf($self->order, \$pdf, {media => $::form->{media},
315 format => $::form->{print_options}->{format},
316 formname => $::form->{print_options}->{formname},
317 language => $language,
318 groupitems => $::form->{print_options}->{groupitems}});
319 if (scalar @errors) {
320 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
323 my $sfile = SL::SessionFile::Random->new(mode => "w");
324 $sfile->fh->print($pdf);
327 $::form->{tmpfile} = $sfile->file_name;
328 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
331 $::form->send_email(\%::myconfig, 'pdf');
334 my $intnotes = $self->order->intnotes;
335 $intnotes .= "\n\n" if $self->order->intnotes;
336 $intnotes .= t8('[email]') . "\n";
337 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
338 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
339 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
340 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
341 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
342 $intnotes .= t8('Message') . ": " . $::form->{message};
345 ->val('#order_intnotes', $intnotes)
346 ->run('kivi.Order.close_email_dialog')
347 ->flash('info', t8('The email has been sent.'))
351 # save the order and redirect to the frontend subroutine for a new
353 sub action_save_and_delivery_order {
356 my $errors = $self->_save();
358 if (scalar @{ $errors }) {
359 $self->js->flash('error', $_) foreach @{ $errors };
360 return $self->js->render();
362 flash_later('info', $::locale->text('The order has been saved'));
364 my @redirect_params = (
365 controller => 'oe.pl',
366 action => 'oe_delivery_order_from_order',
367 id => $self->order->id,
370 $self->redirect_to(@redirect_params);
373 # save the order and redirect to the frontend subroutine for a new
375 sub action_save_and_invoice {
378 my $errors = $self->_save();
380 if (scalar @{ $errors }) {
381 $self->js->flash('error', $_) foreach @{ $errors };
382 return $self->js->render();
384 flash_later('info', $::locale->text('The order has been saved'));
386 my @redirect_params = (
387 controller => 'oe.pl',
388 action => 'oe_invoice_from_order',
389 id => $self->order->id,
392 $self->redirect_to(@redirect_params);
395 # set form elements in respect to a changed customer or vendor
397 # This action is called on an change of the customer/vendor picker.
398 sub action_customer_vendor_changed {
401 my $cv_method = $self->cv;
403 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
404 $self->js->show('#cp_row');
406 $self->js->hide('#cp_row');
409 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
410 $self->js->show('#shipto_row');
412 $self->js->hide('#shipto_row');
415 $self->order->taxzone_id($self->order->$cv_method->taxzone_id);
417 if ($self->order->is_sales) {
418 $self->order->taxincluded(defined($self->order->$cv_method->taxincluded_checked)
419 ? $self->order->$cv_method->taxincluded_checked
420 : $::myconfig{taxincluded_checked});
421 $self->js->val('#order_salesman_id', $self->order->$cv_method->salesman_id);
424 $self->order->payment_id($self->order->$cv_method->payment_id);
425 $self->order->delivery_term_id($self->order->$cv_method->delivery_term_id);
430 ->replaceWith('#order_cp_id', $self->build_contact_select)
431 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
432 ->val( '#order_taxzone_id', $self->order->taxzone_id)
433 ->val( '#order_taxincluded', $self->order->taxincluded)
434 ->val( '#order_payment_id', $self->order->payment_id)
435 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
436 ->val( '#order_intnotes', $self->order->$cv_method->notes)
437 ->focus( '#order_' . $self->cv . '_id');
439 $self->_js_redisplay_amounts_and_taxes;
443 # called if a unit in an existing item row is changed
444 sub action_unit_changed {
447 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
448 my $item = $self->order->items_sorted->[$idx];
450 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
451 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
456 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
457 $self->_js_redisplay_line_values;
458 $self->_js_redisplay_amounts_and_taxes;
462 # add an item row for a new item entered in the input row
463 sub action_add_item {
466 my $form_attr = $::form->{add_item};
468 return unless $form_attr->{parts_id};
470 my $item = _new_item($self->order, $form_attr);
472 $self->order->add_items($item);
476 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
477 my $row_as_html = $self->p->render('order/tabs/_row',
481 ALL_PRICE_FACTORS => $self->all_price_factors
485 ->append('#row_table_id', $row_as_html);
487 if ( $item->part->is_assortment ) {
488 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
489 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
490 my $attr = { parts_id => $assortment_item->parts_id,
491 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
492 unit => $assortment_item->unit,
493 description => $assortment_item->part->description,
495 my $item = _new_item($self->order, $attr);
497 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
498 $item->discount(1) unless $assortment_item->charge;
500 $self->order->add_items( $item );
502 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
503 my $row_as_html = $self->p->render('order/tabs/_row',
507 ALL_PRICE_FACTORS => $self->all_price_factors
510 ->append('#row_table_id', $row_as_html);
515 ->val('.add_item_input', '')
516 ->run('kivi.Order.init_row_handlers')
517 ->run('kivi.Order.row_table_scroll_down')
518 ->run('kivi.Order.renumber_positions')
519 ->focus('#add_item_parts_id_name');
521 $self->_js_redisplay_amounts_and_taxes;
525 # open the dialog for entering multiple items at once
526 sub action_show_multi_items_dialog {
527 require SL::DB::PartsGroup;
528 $_[0]->render('order/tabs/_multi_items_dialog', { layout => 0 },
529 all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
532 # update the filter results in the multi item dialog
533 sub action_multi_items_update_result {
536 $::form->{multi_items}->{filter}->{obsolete} = 0;
538 my $count = $_[0]->multi_items_models->count;
541 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
542 $_[0]->render($text, { layout => 0 });
543 } elsif ($count > $max_count) {
544 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
545 $_[0]->render($text, { layout => 0 });
547 my $multi_items = $_[0]->multi_items_models->get;
548 $_[0]->render('order/tabs/_multi_items_result', { layout => 0 },
549 multi_items => $multi_items);
553 # add item rows for multiple items at once
554 sub action_add_multi_items {
557 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_multi_items} };
558 return $self->js->render() unless scalar @form_attr;
561 foreach my $attr (@form_attr) {
562 my $item = _new_item($self->order, $attr);
564 if ( $item->part->is_assortment ) {
565 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
566 my $attr = { parts_id => $assortment_item->parts_id,
567 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
568 unit => $assortment_item->unit,
569 description => $assortment_item->part->description,
571 my $item = _new_item($self->order, $attr);
573 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
574 $item->discount(1) unless $assortment_item->charge;
579 $self->order->add_items(@items);
583 foreach my $item (@items) {
584 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
585 my $row_as_html = $self->p->render('order/tabs/_row',
589 ALL_PRICE_FACTORS => $self->all_price_factors
592 $self->js->append('#row_table_id', $row_as_html);
596 ->run('kivi.Order.close_multi_items_dialog')
597 ->run('kivi.Order.init_row_handlers')
598 ->run('kivi.Order.row_table_scroll_down')
599 ->run('kivi.Order.renumber_positions')
600 ->focus('#add_item_parts_id_name');
602 $self->_js_redisplay_amounts_and_taxes;
606 # recalculate all linetotals, amounts and taxes and redisplay them
607 sub action_recalc_amounts_and_taxes {
612 $self->_js_redisplay_line_values;
613 $self->_js_redisplay_amounts_and_taxes;
617 # redisplay item rows if they are sorted by an attribute
618 sub action_reorder_items {
622 partnumber => sub { $_[0]->part->partnumber },
623 description => sub { $_[0]->description },
624 qty => sub { $_[0]->qty },
625 sellprice => sub { $_[0]->sellprice },
626 discount => sub { $_[0]->discount },
629 my $method = $sort_keys{$::form->{order_by}};
630 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
631 if ($::form->{sort_dir}) {
632 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
634 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
637 ->run('kivi.Order.redisplay_items', \@to_sort)
641 # show the popup to choose a price/discount source
642 sub action_price_popup {
645 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
646 my $item = $self->order->items_sorted->[$idx];
648 $self->render_price_dialog($item);
651 # get the longdescription for an item if the dialog to enter/change the
652 # longdescription was opened and the longdescription is empty
654 # If this item is new, get the longdescription from Part.
655 # Otherwise get it from OrderItem.
656 sub action_get_item_longdescription {
659 if ($::form->{item_id}) {
660 $longdescription = SL::DB::OrderItem->new(id => $::form->{item_id})->load->longdescription;
661 } elsif ($::form->{parts_id}) {
662 $longdescription = SL::DB::Part->new(id => $::form->{parts_id})->load->notes;
664 $_[0]->render(\ $longdescription, { type => 'text' });
667 # load the second row for one or more items
669 # This action gets the html code for all items second rows by rendering a template for
670 # the second row and sets the html code via client js.
671 sub action_load_second_rows {
674 $self->_recalc() if $self->order->is_sales; # for margin calculation
676 foreach my $item_id (@{ $::form->{item_ids} }) {
677 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
678 my $item = $self->order->items_sorted->[$idx];
680 $self->_js_load_second_row($item, $item_id, 0);
683 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
688 sub _js_load_second_row {
689 my ($self, $item, $item_id, $do_parse) = @_;
692 # Parse values from form (they are formated while rendering (template)).
693 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
694 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
695 foreach my $var (@{ $item->cvars_by_config }) {
696 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
698 $item->parse_custom_variable_values;
701 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
704 ->html('.row_entry:has(#item_' . $item_id . ') [name = "second_row"]', $row_as_html)
705 ->data('.row_entry:has(#item_' . $item_id . ') [name = "second_row"]', 'loaded', 1);
708 sub _js_redisplay_line_values {
711 my $is_sales = $self->order->is_sales;
713 # sales orders with margins
718 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
719 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
720 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
721 ]} @{ $self->order->items_sorted };
725 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
726 ]} @{ $self->order->items_sorted };
730 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
733 sub _js_redisplay_amounts_and_taxes {
736 if (scalar @{ $self->{taxes} }) {
737 $self->js->show('#taxincluded_row_id');
739 $self->js->hide('#taxincluded_row_id');
742 if ($self->order->taxincluded) {
743 $self->js->hide('#subtotal_row_id');
745 $self->js->show('#subtotal_row_id');
749 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
750 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
752 ->insertBefore($self->build_tax_rows, '#amount_row_id');
759 sub init_valid_types {
760 [ _sales_order_type(), _purchase_order_type() ];
766 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
767 die "Not a valid type for order";
770 $self->type($::form->{type});
776 my $cv = $self->type eq _sales_order_type() ? 'customer'
777 : $self->type eq _purchase_order_type() ? 'vendor'
778 : die "Not a valid type for order";
791 # model used to filter/display the parts in the multi-items dialog
792 sub init_multi_items_models {
793 SL::Controller::Helper::GetModels->new(
796 with_objects => [ qw(unit_obj) ],
797 disable_plugin => 'paginated',
798 source => $::form->{multi_items},
804 partnumber => t8('Partnumber'),
805 description => t8('Description')}
809 sub init_all_price_factors {
810 SL::DB::Manager::PriceFactor->get_all;
816 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
818 my $right = $right_for->{ $self->type };
819 $right ||= 'DOES_NOT_EXIST';
821 $::auth->assert($right);
824 # build the selection box for contacts
826 # Needed, if customer/vendor changed.
827 sub build_contact_select {
830 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
831 value_key => 'cp_id',
832 title_key => 'full_name_dep',
833 default => $self->order->cp_id,
835 style => 'width: 300px',
839 # build the selection box for shiptos
841 # Needed, if customer/vendor changed.
842 sub build_shipto_select {
845 select_tag('order.shipto_id', [ $self->order->{$self->cv}->shipto ],
846 value_key => 'shipto_id',
847 title_key => 'displayable_id',
848 default => $self->order->shipto_id,
850 style => 'width: 300px',
854 # build the rows for displaying taxes
856 # Called if amounts where recalculated and redisplayed.
861 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
862 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
864 return $rows_as_html;
868 sub render_price_dialog {
869 my ($self, $record_item) = @_;
871 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
875 'kivi.io.price_chooser_dialog',
876 t8('Available Prices'),
877 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
882 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
883 # $self->js->show('#dialog_flash_error');
892 return if !$::form->{id};
894 $self->order(SL::DB::Manager::Order->find_by(id => $::form->{id}));
897 # load or create a new order object
899 # And assign changes from the for to this object.
900 # If the order is loaded from db, check if items are deleted in the form,
901 # remove them form the object and collect them for removing from db on saving.
902 # Then create/update items from form (via _make_item) and add them.
906 # add_items adds items to an order with no items for saving, but they cannot
907 # be retrieved via items until the order is saved. Adding empty items to new
908 # order here solves this problem.
910 $order = SL::DB::Manager::Order->find_by(id => $::form->{id}) if $::form->{id};
911 $order ||= SL::DB::Order->new(orderitems => []);
913 my $form_orderitems = delete $::form->{order}->{orderitems};
914 $order->assign_attributes(%{$::form->{order}});
916 # remove deleted items
917 $self->item_ids_to_delete([]);
918 foreach my $idx (reverse 0..$#{$order->orderitems}) {
919 my $item = $order->orderitems->[$idx];
920 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
921 splice @{$order->orderitems}, $idx, 1;
922 push @{$self->item_ids_to_delete}, $item->id;
928 foreach my $form_attr (@{$form_orderitems}) {
929 my $item = _make_item($order, $form_attr);
930 $item->position($pos);
934 $order->add_items(grep {!$_->id} @items);
939 # create or update items from form
941 # Make item objects from form values. For items already existing read from db.
942 # Create a new item else. And assign attributes.
944 my ($record, $attr) = @_;
947 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
951 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
952 # they cannot be retrieved via custom_variables until the order/orderitem is
953 # saved. Adding empty custom_variables to new orderitem here solves this problem.
954 $item ||= SL::DB::OrderItem->new(custom_variables => []);
956 $item->assign_attributes(%$attr);
957 $item->longdescription($item->part->notes) if $is_new && !defined $attr->{longdescription};
958 $item->project_id($record->globalproject_id) if $is_new && !defined $attr->{project_id};
959 $item->lastcost($item->part->lastcost) if $is_new && !defined $attr->{lastcost_as_number};
966 # This is used to add one item
968 my ($record, $attr) = @_;
970 my $item = SL::DB::OrderItem->new;
971 $item->assign_attributes(%$attr);
973 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
974 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
976 $item->unit($part->unit) if !$item->unit;
979 if ( $part->is_assortment ) {
980 # add assortment items with price 0, as the components carry the price
981 $price_src = $price_source->price_from_source("");
982 $price_src->price(0);
983 } elsif ($item->sellprice) {
984 $price_src = $price_source->price_from_source("");
985 $price_src->price($item->sellprice);
987 $price_src = $price_source->best_price
988 ? $price_source->best_price
989 : $price_source->price_from_source("");
990 $price_src->price(0) if !$price_source->best_price;
994 if ($item->discount) {
995 $discount_src = $price_source->discount_from_source("");
996 $discount_src->discount($item->discount);
998 $discount_src = $price_source->best_discount
999 ? $price_source->best_discount
1000 : $price_source->discount_from_source("");
1001 $discount_src->discount(0) if !$price_source->best_discount;
1005 $new_attr{part} = $part;
1006 $new_attr{description} = $part->description if ! $item->description;
1007 $new_attr{qty} = 1.0 if ! $item->qty;
1008 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1009 $new_attr{sellprice} = $price_src->price;
1010 $new_attr{discount} = $discount_src->discount;
1011 $new_attr{active_price_source} = $price_src;
1012 $new_attr{active_discount_source} = $discount_src;
1013 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1014 $new_attr{project_id} = $record->globalproject_id;
1015 $new_attr{lastcost} = $part->lastcost;
1017 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1018 # they cannot be retrieved via custom_variables until the order/orderitem is
1019 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1020 $new_attr{custom_variables} = [];
1022 $item->assign_attributes(%new_attr);
1027 # recalculate prices and taxes
1029 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1033 # bb: todo: currency later
1034 $self->order->currency_id($::instance_conf->get_currency_id());
1036 my %pat = $self->order->calculate_prices_and_taxes();
1037 $self->{taxes} = [];
1038 foreach my $tax_chart_id (keys %{ $pat{taxes} }) {
1039 my $tax = SL::DB::Manager::Tax->find_by(chart_id => $tax_chart_id);
1041 my @amount_keys = grep { $pat{amounts}->{$_}->{tax_id} == $tax->id } keys %{ $pat{amounts} };
1042 push(@{ $self->{taxes} }, { amount => $pat{taxes}->{$tax_chart_id},
1043 netamount => $pat{amounts}->{$amount_keys[0]}->{amount},
1047 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items}, @{$pat{items}};
1050 # get data for saving, printing, ..., that is not changed in the form
1052 # Only cvars for now.
1053 sub _get_unalterable_data {
1056 foreach my $item (@{ $self->order->items }) {
1057 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1058 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1059 foreach my $var (@{ $item->cvars_by_config }) {
1060 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1062 $item->parse_custom_variable_values;
1068 # And remove related files in the spool directory
1073 my $db = $self->order->db;
1075 $db->with_transaction(
1077 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1078 $self->order->delete;
1079 my $spool = $::lx_office_conf{paths}->{spool};
1080 unlink map { "$spool/$_" } @spoolfiles if $spool;
1083 }) || push(@{$errors}, $db->error);
1090 # And delete items that are deleted in the form.
1095 my $db = $self->order->db;
1097 $db->with_transaction(sub {
1098 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete};
1099 $self->order->save(cascade => 1);
1100 }) || push(@{$errors}, $db->error);
1109 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1110 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1111 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1114 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1117 $self->{all_projects} = SL::DB::Manager::Project->get_all(where => [ or => [ id => $self->order->globalproject_id,
1119 sort_by => 'projectnumber');
1120 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1123 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1125 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1127 my $print_form = Form->new('');
1128 $print_form->{type} = $self->type;
1129 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1130 $print_form->{languages} = SL::DB::Manager::Language->get_all_sorted;
1131 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1132 form => $print_form,
1133 options => {dialog_name_prefix => 'print_options.',
1137 no_opendocument => 1,
1141 foreach my $item (@{$self->order->orderitems}) {
1142 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1143 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1144 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1147 if ($self->order->ordnumber && $::instance_conf->get_webdav) {
1148 my $webdav = SL::Webdav->new(
1149 type => $self->type,
1150 number => $self->order->ordnumber,
1152 my @all_objects = $webdav->get_all_objects;
1153 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1155 link => File::Spec->catfile($_->full_filedescriptor),
1159 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery);
1160 $self->_setup_edit_action_bar;
1163 sub _setup_edit_action_bar {
1164 my ($self, %params) = @_;
1166 my $deletion_allowed = (($self->cv eq 'customer') && $::instance_conf->get_sales_order_show_delete)
1167 || (($self->cv eq 'vendor') && $::instance_conf->get_purchase_order_show_delete);
1169 for my $bar ($::request->layout->get('actionbar')) {
1174 call => [ 'kivi.Order.save', $::instance_conf->get_order_warn_duplicate_parts ],
1175 accesskey => 'enter',
1178 t8('Save and Delivery Order'),
1179 call => [ 'kivi.Order.save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts ],
1182 t8('Save and Invoice'),
1183 call => [ 'kivi.Order.save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1186 ], # end of combobox "Save"
1194 call => [ 'kivi.Order.show_print_options' ],
1198 call => [ 'kivi.Order.email' ],
1201 t8('Download attachments of all parts'),
1202 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1203 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1204 only_if => $::instance_conf->get_doc_storage,
1206 ], # end of combobox "Export"
1210 call => [ 'kivi.Order.delete_order' ],
1211 confirm => $::locale->text('Do you really want to delete this object?'),
1212 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1213 only_if => $deletion_allowed,
1220 my ($order, $pdf_ref, $params) = @_;
1224 my $print_form = Form->new('');
1225 $print_form->{type} = $order->type;
1226 $print_form->{formname} = $params->{formname} || $order->type;
1227 $print_form->{format} = $params->{format} || 'pdf';
1228 $print_form->{media} = $params->{media} || 'file';
1229 $print_form->{groupitems} = $params->{groupitems};
1230 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1232 $order->language($params->{language});
1233 $order->flatten_to_form($print_form, format_amounts => 1);
1235 # search for the template
1236 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1237 name => $print_form->{formname},
1238 email => $print_form->{media} eq 'email',
1239 language => $params->{language},
1240 printer_id => $print_form->{printer_id}, # todo
1243 if (!defined $template_file) {
1244 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);
1247 return @errors if scalar @errors;
1249 $print_form->throw_on_error(sub {
1251 $print_form->prepare_for_printing;
1253 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1254 template => $template_file,
1255 variables => $print_form,
1256 variable_content_types => {
1257 longdescription => 'html',
1258 partnotes => 'html',
1263 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->getMessage : $EVAL_ERROR;
1269 sub _get_files_for_email_dialog {
1272 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
1274 return %files if !$::instance_conf->get_doc_storage;
1276 if ($self->order->id) {
1277 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
1278 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
1279 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
1283 uniq_by { $_->{id} }
1285 +{ id => $_->part->id,
1286 partnumber => $_->part->partnumber }
1287 } @{$self->order->items_sorted};
1289 foreach my $part (@parts) {
1290 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
1291 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
1294 foreach my $key (keys %files) {
1295 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
1301 sub _sales_order_type {
1305 sub _purchase_order_type {
1317 SL::Controller::Order - controller for orders
1321 This is a new form to enter orders, completely rewritten with the use
1322 of controller and java script techniques.
1324 The aim is to provide the user a better expirience and a faster flow
1325 of work. Also the code should be more readable, more reliable and
1334 One input row, so that input happens every time at the same place.
1338 Use of pickers where possible.
1342 Possibility to enter more than one item at once.
1346 Save order only on "save" (and "save and delivery order"-workflow). No
1347 hidden save on "print" or "email".
1351 Item list in a scrollable area, so that the workflow buttons stay at
1356 Reordering item rows with drag and drop is possible. Sorting item rows is
1357 possible (by partnumber, description, qty, sellprice and discount for now).
1361 No C<update> is necessary. All entries and calculations are managed
1362 with ajax-calls and the page does only reload on C<save>.
1366 User can see changes immediately, because of the use of java script
1377 =item * C<SL/Controller/Order.pm>
1381 =item * C<template/webpages/order/form.html>
1385 =item * C<template/webpages/order/tabs/basic_data.html>
1387 Main tab for basic_data.
1389 This is the only tab here for now. "linked records" and "webdav" tabs are
1390 reused from generic code.
1394 =item * C<template/webpages/order/tabs/_item_input.html>
1396 The input line for items
1398 =item * C<template/webpages/order/tabs/_row.html>
1400 One row for already entered items
1402 =item * C<template/webpages/order/tabs/_tax_row.html>
1404 Displaying tax information
1406 =item * C<template/webpages/order/tabs/_multi_items_dialog.html>
1408 Dialog for entering more than one item at once
1410 =item * C<template/webpages/order/tabs/_multi_items_result.html>
1412 Results for the filter in the multi items dialog
1414 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
1416 Dialog for selecting price and discount sources
1420 =item * C<js/kivi.Order.js>
1422 java script functions
1434 =item * customer/vendor details ('D'-button)
1436 =item * credit limit
1438 =item * more workflows (save as new, quotation, purchase order)
1440 =item * price sources: little symbols showing better price / better discount
1442 =item * select units in input row?
1444 =item * custom shipto address
1446 =item * periodic invoices
1448 =item * language / part translations
1450 =item * access rights
1452 =item * display weights
1458 =item * optional client/user behaviour
1460 (transactions has to be set - department has to be set -
1461 force project if enabled in client config - transport cost reminder)
1465 =head1 KNOWN BUGS AND CAVEATS
1471 Customer discount is not displayed as a valid discount in price source popup
1472 (this might be a bug in price sources)
1474 (I cannot reproduce this (Bernd))
1478 No indication that <shift>-up/down expands/collapses second row.
1482 Inline creation of parts is not currently supported
1486 Table header is not sticky in the scrolling area.
1490 Sorting does not include C<position>, neither does reordering.
1492 This behavior was implemented intentionally. But we can discuss, which behavior
1493 should be implemented.
1497 C<show_multi_items_dialog> does not use the currently inserted string for
1502 The language selected in print or email dialog is not saved when the order is saved.
1506 =head1 To discuss / Nice to have
1512 How to expand/collapse second row. Now it can be done clicking the icon or
1517 Possibility to change longdescription in input row?
1521 Possibility to select PriceSources in input row?
1525 This controller uses a (changed) copy of the template for the PriceSource
1526 dialog. Maybe there could be used one code source.
1530 Rounding-differences between this controller (PriceTaxCalculator) and the old
1531 form. This is not only a problem here, but also in all parts using the PTC.
1532 There exists a ticket and a patch. This patch should be testet.
1536 An indicator, if the actual inputs are saved (like in an
1537 editor or on text processing application).
1541 A warning when leaving the page without saveing unchanged inputs.
1545 Workflows for delivery order and invoice are in the menu "Save", because the
1546 order is saved before opening the new document form. Nevertheless perhaps these
1547 workflow buttons should be put under "Workflows".
1554 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>