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 hidden_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(any 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 # open the periodic invoices config dialog
353 # If there are values in the form (i.e. dialog was opened before),
354 # then use this values. Create new ones, else.
355 sub action_show_periodic_invoices_config_dialog {
358 my $config = _make_periodic_invoices_config_from_yaml(delete $::form->{config});
359 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
360 $config ||= SL::DB::PeriodicInvoicesConfig->new(periodicity => 'm',
361 order_value_periodicity => 'p', # = same as periodicity
362 start_date_as_date => $::form->{transdate} || $::form->current_date,
363 extend_automatically_by => 12,
365 email_subject => GenericTranslations->get(
366 language_id => $::form->{language_id},
367 translation_type =>"preset_text_periodic_invoices_email_subject"),
368 email_body => GenericTranslations->get(
369 language_id => $::form->{language_id},
370 translation_type =>"preset_text_periodic_invoices_email_body"),
372 $config->periodicity('m') if none { $_ eq $config->periodicity } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
373 $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
375 $::form->get_lists(printers => "ALL_PRINTERS",
376 charts => { key => 'ALL_CHARTS',
377 transdate => 'current_date' });
379 $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
381 if ($::form->{customer_id}) {
382 $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
385 $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
387 popup_js_close_function => 'kivi.Order.close_periodic_invoices_config_dialog()',
388 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
393 # assign the values of the periodic invoices config dialog
394 # as yaml in the hidden tag and set the status.
395 sub action_assign_periodic_invoices_config {
398 $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
400 my $config = { active => $::form->{active} ? 1 : 0,
401 terminated => $::form->{terminated} ? 1 : 0,
402 direct_debit => $::form->{direct_debit} ? 1 : 0,
403 periodicity => (any { $_ eq $::form->{periodicity} } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES) ? $::form->{periodicity} : 'm',
404 order_value_periodicity => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
405 start_date_as_date => $::form->{start_date_as_date},
406 end_date_as_date => $::form->{end_date_as_date},
407 first_billing_date_as_date => $::form->{first_billing_date_as_date},
408 print => $::form->{print} ? 1 : 0,
409 printer_id => $::form->{print} ? $::form->{printer_id} * 1 : undef,
410 copies => $::form->{copies} * 1 ? $::form->{copies} : 1,
411 extend_automatically_by => $::form->{extend_automatically_by} * 1 || undef,
412 ar_chart_id => $::form->{ar_chart_id} * 1,
413 send_email => $::form->{send_email} ? 1 : 0,
414 email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
415 email_recipient_address => $::form->{email_recipient_address},
416 email_sender => $::form->{email_sender},
417 email_subject => $::form->{email_subject},
418 email_body => $::form->{email_body},
421 my $periodic_invoices_config = YAML::Dump($config);
423 my $status = $self->_get_periodic_invoices_status($config);
426 ->remove('#order_periodic_invoices_config')
427 ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
428 ->run('kivi.Order.close_periodic_invoices_config_dialog')
429 ->html('#periodic_invoices_status', $status)
430 ->flash('info', t8('The periodic invoices config has been assigned.'))
434 sub action_get_has_active_periodic_invoices {
437 my $config = _make_periodic_invoices_config_from_yaml(delete $::form->{config});
438 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
440 my $has_active_periodic_invoices =
441 $self->type eq _sales_order_type()
444 && (!$config->end_date || ($config->end_date > DateTime->today_local))
445 && $config->get_previous_billed_period_start_date;
447 $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
450 # save the order and redirect to the frontend subroutine for a new
452 sub action_save_and_delivery_order {
455 my $errors = $self->_save();
457 if (scalar @{ $errors }) {
458 $self->js->flash('error', $_) foreach @{ $errors };
459 return $self->js->render();
461 flash_later('info', $::locale->text('The order has been saved'));
463 my @redirect_params = (
464 controller => 'oe.pl',
465 action => 'oe_delivery_order_from_order',
466 id => $self->order->id,
469 $self->redirect_to(@redirect_params);
472 # save the order and redirect to the frontend subroutine for a new
474 sub action_save_and_invoice {
477 my $errors = $self->_save();
479 if (scalar @{ $errors }) {
480 $self->js->flash('error', $_) foreach @{ $errors };
481 return $self->js->render();
483 flash_later('info', $::locale->text('The order has been saved'));
485 my @redirect_params = (
486 controller => 'oe.pl',
487 action => 'oe_invoice_from_order',
488 id => $self->order->id,
491 $self->redirect_to(@redirect_params);
494 # set form elements in respect to a changed customer or vendor
496 # This action is called on an change of the customer/vendor picker.
497 sub action_customer_vendor_changed {
500 my $cv_method = $self->cv;
502 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
503 $self->js->show('#cp_row');
505 $self->js->hide('#cp_row');
508 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
509 $self->js->show('#shipto_row');
511 $self->js->hide('#shipto_row');
514 $self->order->taxzone_id($self->order->$cv_method->taxzone_id);
516 if ($self->order->is_sales) {
517 $self->order->taxincluded(defined($self->order->$cv_method->taxincluded_checked)
518 ? $self->order->$cv_method->taxincluded_checked
519 : $::myconfig{taxincluded_checked});
520 $self->js->val('#order_salesman_id', $self->order->$cv_method->salesman_id);
523 $self->order->payment_id($self->order->$cv_method->payment_id);
524 $self->order->delivery_term_id($self->order->$cv_method->delivery_term_id);
529 ->replaceWith('#order_cp_id', $self->build_contact_select)
530 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
531 ->val( '#order_taxzone_id', $self->order->taxzone_id)
532 ->val( '#order_taxincluded', $self->order->taxincluded)
533 ->val( '#order_payment_id', $self->order->payment_id)
534 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
535 ->val( '#order_intnotes', $self->order->$cv_method->notes)
536 ->focus( '#order_' . $self->cv . '_id');
538 $self->_js_redisplay_amounts_and_taxes;
542 # called if a unit in an existing item row is changed
543 sub action_unit_changed {
546 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
547 my $item = $self->order->items_sorted->[$idx];
549 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
550 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
555 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
556 $self->_js_redisplay_line_values;
557 $self->_js_redisplay_amounts_and_taxes;
561 # add an item row for a new item entered in the input row
562 sub action_add_item {
565 my $form_attr = $::form->{add_item};
567 return unless $form_attr->{parts_id};
569 my $item = _new_item($self->order, $form_attr);
571 $self->order->add_items($item);
575 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
576 my $row_as_html = $self->p->render('order/tabs/_row',
580 ALL_PRICE_FACTORS => $self->all_price_factors
584 ->append('#row_table_id', $row_as_html);
586 if ( $item->part->is_assortment ) {
587 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
588 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
589 my $attr = { parts_id => $assortment_item->parts_id,
590 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
591 unit => $assortment_item->unit,
592 description => $assortment_item->part->description,
594 my $item = _new_item($self->order, $attr);
596 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
597 $item->discount(1) unless $assortment_item->charge;
599 $self->order->add_items( $item );
601 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
602 my $row_as_html = $self->p->render('order/tabs/_row',
606 ALL_PRICE_FACTORS => $self->all_price_factors
609 ->append('#row_table_id', $row_as_html);
614 ->val('.add_item_input', '')
615 ->run('kivi.Order.init_row_handlers')
616 ->run('kivi.Order.row_table_scroll_down')
617 ->run('kivi.Order.renumber_positions')
618 ->focus('#add_item_parts_id_name');
620 $self->_js_redisplay_amounts_and_taxes;
624 # open the dialog for entering multiple items at once
625 sub action_show_multi_items_dialog {
626 require SL::DB::PartsGroup;
627 $_[0]->render('order/tabs/_multi_items_dialog', { layout => 0 },
628 all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
631 # update the filter results in the multi item dialog
632 sub action_multi_items_update_result {
635 $::form->{multi_items}->{filter}->{obsolete} = 0;
637 my $count = $_[0]->multi_items_models->count;
640 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
641 $_[0]->render($text, { layout => 0 });
642 } elsif ($count > $max_count) {
643 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
644 $_[0]->render($text, { layout => 0 });
646 my $multi_items = $_[0]->multi_items_models->get;
647 $_[0]->render('order/tabs/_multi_items_result', { layout => 0 },
648 multi_items => $multi_items);
652 # add item rows for multiple items at once
653 sub action_add_multi_items {
656 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_multi_items} };
657 return $self->js->render() unless scalar @form_attr;
660 foreach my $attr (@form_attr) {
661 my $item = _new_item($self->order, $attr);
663 if ( $item->part->is_assortment ) {
664 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
665 my $attr = { parts_id => $assortment_item->parts_id,
666 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
667 unit => $assortment_item->unit,
668 description => $assortment_item->part->description,
670 my $item = _new_item($self->order, $attr);
672 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
673 $item->discount(1) unless $assortment_item->charge;
678 $self->order->add_items(@items);
682 foreach my $item (@items) {
683 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
684 my $row_as_html = $self->p->render('order/tabs/_row',
688 ALL_PRICE_FACTORS => $self->all_price_factors
691 $self->js->append('#row_table_id', $row_as_html);
695 ->run('kivi.Order.close_multi_items_dialog')
696 ->run('kivi.Order.init_row_handlers')
697 ->run('kivi.Order.row_table_scroll_down')
698 ->run('kivi.Order.renumber_positions')
699 ->focus('#add_item_parts_id_name');
701 $self->_js_redisplay_amounts_and_taxes;
705 # recalculate all linetotals, amounts and taxes and redisplay them
706 sub action_recalc_amounts_and_taxes {
711 $self->_js_redisplay_line_values;
712 $self->_js_redisplay_amounts_and_taxes;
716 # redisplay item rows if they are sorted by an attribute
717 sub action_reorder_items {
721 partnumber => sub { $_[0]->part->partnumber },
722 description => sub { $_[0]->description },
723 qty => sub { $_[0]->qty },
724 sellprice => sub { $_[0]->sellprice },
725 discount => sub { $_[0]->discount },
728 my $method = $sort_keys{$::form->{order_by}};
729 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
730 if ($::form->{sort_dir}) {
731 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
733 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
736 ->run('kivi.Order.redisplay_items', \@to_sort)
740 # show the popup to choose a price/discount source
741 sub action_price_popup {
744 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
745 my $item = $self->order->items_sorted->[$idx];
747 $self->render_price_dialog($item);
750 # get the longdescription for an item if the dialog to enter/change the
751 # longdescription was opened and the longdescription is empty
753 # If this item is new, get the longdescription from Part.
754 # Otherwise get it from OrderItem.
755 sub action_get_item_longdescription {
758 if ($::form->{item_id}) {
759 $longdescription = SL::DB::OrderItem->new(id => $::form->{item_id})->load->longdescription;
760 } elsif ($::form->{parts_id}) {
761 $longdescription = SL::DB::Part->new(id => $::form->{parts_id})->load->notes;
763 $_[0]->render(\ $longdescription, { type => 'text' });
766 # load the second row for one or more items
768 # This action gets the html code for all items second rows by rendering a template for
769 # the second row and sets the html code via client js.
770 sub action_load_second_rows {
773 $self->_recalc() if $self->order->is_sales; # for margin calculation
775 foreach my $item_id (@{ $::form->{item_ids} }) {
776 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
777 my $item = $self->order->items_sorted->[$idx];
779 $self->_js_load_second_row($item, $item_id, 0);
782 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
787 sub _js_load_second_row {
788 my ($self, $item, $item_id, $do_parse) = @_;
791 # Parse values from form (they are formated while rendering (template)).
792 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
793 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
794 foreach my $var (@{ $item->cvars_by_config }) {
795 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
797 $item->parse_custom_variable_values;
800 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
803 ->html('.row_entry:has(#item_' . $item_id . ') [name = "second_row"]', $row_as_html)
804 ->data('.row_entry:has(#item_' . $item_id . ') [name = "second_row"]', 'loaded', 1);
807 sub _js_redisplay_line_values {
810 my $is_sales = $self->order->is_sales;
812 # sales orders with margins
817 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
818 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
819 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
820 ]} @{ $self->order->items_sorted };
824 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
825 ]} @{ $self->order->items_sorted };
829 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
832 sub _js_redisplay_amounts_and_taxes {
835 if (scalar @{ $self->{taxes} }) {
836 $self->js->show('#taxincluded_row_id');
838 $self->js->hide('#taxincluded_row_id');
841 if ($self->order->taxincluded) {
842 $self->js->hide('#subtotal_row_id');
844 $self->js->show('#subtotal_row_id');
848 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
849 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
851 ->insertBefore($self->build_tax_rows, '#amount_row_id');
858 sub init_valid_types {
859 [ _sales_order_type(), _purchase_order_type() ];
865 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
866 die "Not a valid type for order";
869 $self->type($::form->{type});
875 my $cv = $self->type eq _sales_order_type() ? 'customer'
876 : $self->type eq _purchase_order_type() ? 'vendor'
877 : die "Not a valid type for order";
890 # model used to filter/display the parts in the multi-items dialog
891 sub init_multi_items_models {
892 SL::Controller::Helper::GetModels->new(
895 with_objects => [ qw(unit_obj) ],
896 disable_plugin => 'paginated',
897 source => $::form->{multi_items},
903 partnumber => t8('Partnumber'),
904 description => t8('Description')}
908 sub init_all_price_factors {
909 SL::DB::Manager::PriceFactor->get_all;
915 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
917 my $right = $right_for->{ $self->type };
918 $right ||= 'DOES_NOT_EXIST';
920 $::auth->assert($right);
923 # build the selection box for contacts
925 # Needed, if customer/vendor changed.
926 sub build_contact_select {
929 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
930 value_key => 'cp_id',
931 title_key => 'full_name_dep',
932 default => $self->order->cp_id,
934 style => 'width: 300px',
938 # build the selection box for shiptos
940 # Needed, if customer/vendor changed.
941 sub build_shipto_select {
944 select_tag('order.shipto_id', [ $self->order->{$self->cv}->shipto ],
945 value_key => 'shipto_id',
946 title_key => 'displayable_id',
947 default => $self->order->shipto_id,
949 style => 'width: 300px',
953 # build the rows for displaying taxes
955 # Called if amounts where recalculated and redisplayed.
960 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
961 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
963 return $rows_as_html;
967 sub render_price_dialog {
968 my ($self, $record_item) = @_;
970 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
974 'kivi.io.price_chooser_dialog',
975 t8('Available Prices'),
976 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
981 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
982 # $self->js->show('#dialog_flash_error');
991 return if !$::form->{id};
993 $self->order(SL::DB::Manager::Order->find_by(id => $::form->{id}));
996 # load or create a new order object
998 # And assign changes from the form to this object.
999 # If the order is loaded from db, check if items are deleted in the form,
1000 # remove them form the object and collect them for removing from db on saving.
1001 # Then create/update items from form (via _make_item) and add them.
1005 # add_items adds items to an order with no items for saving, but they cannot
1006 # be retrieved via items until the order is saved. Adding empty items to new
1007 # order here solves this problem.
1009 $order = SL::DB::Manager::Order->find_by(id => $::form->{id}) if $::form->{id};
1010 $order ||= SL::DB::Order->new(orderitems => []);
1012 my $form_orderitems = delete $::form->{order}->{orderitems};
1013 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1015 $order->assign_attributes(%{$::form->{order}});
1017 my $periodic_invoices_config = _make_periodic_invoices_config_from_yaml($form_periodic_invoices_config);
1018 $order->periodic_invoices_config($periodic_invoices_config) if $periodic_invoices_config;
1020 # remove deleted items
1021 $self->item_ids_to_delete([]);
1022 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1023 my $item = $order->orderitems->[$idx];
1024 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1025 splice @{$order->orderitems}, $idx, 1;
1026 push @{$self->item_ids_to_delete}, $item->id;
1032 foreach my $form_attr (@{$form_orderitems}) {
1033 my $item = _make_item($order, $form_attr);
1034 $item->position($pos);
1038 $order->add_items(grep {!$_->id} @items);
1043 # create or update items from form
1045 # Make item objects from form values. For items already existing read from db.
1046 # Create a new item else. And assign attributes.
1048 my ($record, $attr) = @_;
1051 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1053 my $is_new = !$item;
1055 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1056 # they cannot be retrieved via custom_variables until the order/orderitem is
1057 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1058 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1060 $item->assign_attributes(%$attr);
1061 $item->longdescription($item->part->notes) if $is_new && !defined $attr->{longdescription};
1062 $item->project_id($record->globalproject_id) if $is_new && !defined $attr->{project_id};
1063 $item->lastcost($item->part->lastcost) if $is_new && !defined $attr->{lastcost_as_number};
1070 # This is used to add one item
1072 my ($record, $attr) = @_;
1074 my $item = SL::DB::OrderItem->new;
1075 $item->assign_attributes(%$attr);
1077 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1078 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1080 $item->unit($part->unit) if !$item->unit;
1083 if ( $part->is_assortment ) {
1084 # add assortment items with price 0, as the components carry the price
1085 $price_src = $price_source->price_from_source("");
1086 $price_src->price(0);
1087 } elsif ($item->sellprice) {
1088 $price_src = $price_source->price_from_source("");
1089 $price_src->price($item->sellprice);
1091 $price_src = $price_source->best_price
1092 ? $price_source->best_price
1093 : $price_source->price_from_source("");
1094 $price_src->price(0) if !$price_source->best_price;
1098 if ($item->discount) {
1099 $discount_src = $price_source->discount_from_source("");
1100 $discount_src->discount($item->discount);
1102 $discount_src = $price_source->best_discount
1103 ? $price_source->best_discount
1104 : $price_source->discount_from_source("");
1105 $discount_src->discount(0) if !$price_source->best_discount;
1109 $new_attr{part} = $part;
1110 $new_attr{description} = $part->description if ! $item->description;
1111 $new_attr{qty} = 1.0 if ! $item->qty;
1112 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1113 $new_attr{sellprice} = $price_src->price;
1114 $new_attr{discount} = $discount_src->discount;
1115 $new_attr{active_price_source} = $price_src;
1116 $new_attr{active_discount_source} = $discount_src;
1117 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1118 $new_attr{project_id} = $record->globalproject_id;
1119 $new_attr{lastcost} = $part->lastcost;
1121 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1122 # they cannot be retrieved via custom_variables until the order/orderitem is
1123 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1124 $new_attr{custom_variables} = [];
1126 $item->assign_attributes(%new_attr);
1131 # recalculate prices and taxes
1133 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1137 # bb: todo: currency later
1138 $self->order->currency_id($::instance_conf->get_currency_id());
1140 my %pat = $self->order->calculate_prices_and_taxes();
1141 $self->{taxes} = [];
1142 foreach my $tax_chart_id (keys %{ $pat{taxes} }) {
1143 my $tax = SL::DB::Manager::Tax->find_by(chart_id => $tax_chart_id);
1145 my @amount_keys = grep { $pat{amounts}->{$_}->{tax_id} == $tax->id } keys %{ $pat{amounts} };
1146 push(@{ $self->{taxes} }, { amount => $pat{taxes}->{$tax_chart_id},
1147 netamount => $pat{amounts}->{$amount_keys[0]}->{amount},
1151 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items}, @{$pat{items}};
1154 # get data for saving, printing, ..., that is not changed in the form
1156 # Only cvars for now.
1157 sub _get_unalterable_data {
1160 foreach my $item (@{ $self->order->items }) {
1161 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1162 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1163 foreach my $var (@{ $item->cvars_by_config }) {
1164 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1166 $item->parse_custom_variable_values;
1172 # And remove related files in the spool directory
1177 my $db = $self->order->db;
1179 $db->with_transaction(
1181 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1182 $self->order->delete;
1183 my $spool = $::lx_office_conf{paths}->{spool};
1184 unlink map { "$spool/$_" } @spoolfiles if $spool;
1187 }) || push(@{$errors}, $db->error);
1194 # And delete items that are deleted in the form.
1199 my $db = $self->order->db;
1201 $db->with_transaction(sub {
1202 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete};
1203 $self->order->save(cascade => 1);
1204 }) || push(@{$errors}, $db->error);
1213 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1214 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1215 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1218 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1221 $self->{all_projects} = SL::DB::Manager::Project->get_all(where => [ or => [ id => $self->order->globalproject_id,
1223 sort_by => 'projectnumber');
1224 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1226 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1227 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1228 $self->{periodic_invoices_status} = $self->_get_periodic_invoices_status($self->order->periodic_invoices_config);
1230 my $print_form = Form->new('');
1231 $print_form->{type} = $self->type;
1232 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1233 $print_form->{languages} = SL::DB::Manager::Language->get_all_sorted;
1234 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1235 form => $print_form,
1236 options => {dialog_name_prefix => 'print_options.',
1240 no_opendocument => 1,
1244 foreach my $item (@{$self->order->orderitems}) {
1245 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1246 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1247 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1250 if ($self->order->ordnumber && $::instance_conf->get_webdav) {
1251 my $webdav = SL::Webdav->new(
1252 type => $self->type,
1253 number => $self->order->ordnumber,
1255 my @all_objects = $webdav->get_all_objects;
1256 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1258 link => File::Spec->catfile($_->full_filedescriptor),
1262 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery edit_periodic_invoices_config);
1263 $self->_setup_edit_action_bar;
1266 sub _setup_edit_action_bar {
1267 my ($self, %params) = @_;
1269 my $deletion_allowed = (($self->cv eq 'customer') && $::instance_conf->get_sales_order_show_delete)
1270 || (($self->cv eq 'vendor') && $::instance_conf->get_purchase_order_show_delete);
1272 for my $bar ($::request->layout->get('actionbar')) {
1277 call => [ 'kivi.Order.save', $::instance_conf->get_order_warn_duplicate_parts ],
1278 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1279 accesskey => 'enter',
1282 t8('Save and Delivery Order'),
1283 call => [ 'kivi.Order.save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts ],
1284 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1287 t8('Save and Invoice'),
1288 call => [ 'kivi.Order.save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1289 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1292 ], # end of combobox "Save"
1300 call => [ 'kivi.Order.show_print_options' ],
1304 call => [ 'kivi.Order.email' ],
1307 t8('Download attachments of all parts'),
1308 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1309 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1310 only_if => $::instance_conf->get_doc_storage,
1312 ], # end of combobox "Export"
1316 call => [ 'kivi.Order.delete_order' ],
1317 confirm => $::locale->text('Do you really want to delete this object?'),
1318 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1319 only_if => $deletion_allowed,
1326 my ($order, $pdf_ref, $params) = @_;
1330 my $print_form = Form->new('');
1331 $print_form->{type} = $order->type;
1332 $print_form->{formname} = $params->{formname} || $order->type;
1333 $print_form->{format} = $params->{format} || 'pdf';
1334 $print_form->{media} = $params->{media} || 'file';
1335 $print_form->{groupitems} = $params->{groupitems};
1336 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1338 $order->language($params->{language});
1339 $order->flatten_to_form($print_form, format_amounts => 1);
1341 # search for the template
1342 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1343 name => $print_form->{formname},
1344 email => $print_form->{media} eq 'email',
1345 language => $params->{language},
1346 printer_id => $print_form->{printer_id}, # todo
1349 if (!defined $template_file) {
1350 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);
1353 return @errors if scalar @errors;
1355 $print_form->throw_on_error(sub {
1357 $print_form->prepare_for_printing;
1359 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1360 template => $template_file,
1361 variables => $print_form,
1362 variable_content_types => {
1363 longdescription => 'html',
1364 partnotes => 'html',
1369 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->getMessage : $EVAL_ERROR;
1375 sub _get_files_for_email_dialog {
1378 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
1380 return %files if !$::instance_conf->get_doc_storage;
1382 if ($self->order->id) {
1383 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
1384 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
1385 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
1389 uniq_by { $_->{id} }
1391 +{ id => $_->part->id,
1392 partnumber => $_->part->partnumber }
1393 } @{$self->order->items_sorted};
1395 foreach my $part (@parts) {
1396 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
1397 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
1400 foreach my $key (keys %files) {
1401 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
1407 sub _make_periodic_invoices_config_from_yaml {
1408 my ($yaml_config) = @_;
1410 return if !$yaml_config;
1411 my $attr = YAML::Load($yaml_config);
1412 return if 'HASH' ne ref $attr;
1413 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
1417 sub _get_periodic_invoices_status {
1418 my ($self, $config) = @_;
1420 return if $self->type ne _sales_order_type();
1421 return t8('not configured') if !$config;
1423 my $active = ('HASH' eq ref $config) ? $config->{active}
1424 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
1425 : die "Cannot get status of periodic invoices config";
1427 return $active ? t8('active') : t8('inactive');
1430 sub _sales_order_type {
1434 sub _purchase_order_type {
1446 SL::Controller::Order - controller for orders
1450 This is a new form to enter orders, completely rewritten with the use
1451 of controller and java script techniques.
1453 The aim is to provide the user a better expirience and a faster flow
1454 of work. Also the code should be more readable, more reliable and
1463 One input row, so that input happens every time at the same place.
1467 Use of pickers where possible.
1471 Possibility to enter more than one item at once.
1475 Save order only on "save" (and "save and delivery order"-workflow). No
1476 hidden save on "print" or "email".
1480 Item list in a scrollable area, so that the workflow buttons stay at
1485 Reordering item rows with drag and drop is possible. Sorting item rows is
1486 possible (by partnumber, description, qty, sellprice and discount for now).
1490 No C<update> is necessary. All entries and calculations are managed
1491 with ajax-calls and the page does only reload on C<save>.
1495 User can see changes immediately, because of the use of java script
1506 =item * C<SL/Controller/Order.pm>
1510 =item * C<template/webpages/order/form.html>
1514 =item * C<template/webpages/order/tabs/basic_data.html>
1516 Main tab for basic_data.
1518 This is the only tab here for now. "linked records" and "webdav" tabs are
1519 reused from generic code.
1523 =item * C<template/webpages/order/tabs/_item_input.html>
1525 The input line for items
1527 =item * C<template/webpages/order/tabs/_row.html>
1529 One row for already entered items
1531 =item * C<template/webpages/order/tabs/_tax_row.html>
1533 Displaying tax information
1535 =item * C<template/webpages/order/tabs/_multi_items_dialog.html>
1537 Dialog for entering more than one item at once
1539 =item * C<template/webpages/order/tabs/_multi_items_result.html>
1541 Results for the filter in the multi items dialog
1543 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
1545 Dialog for selecting price and discount sources
1549 =item * C<js/kivi.Order.js>
1551 java script functions
1563 =item * customer/vendor details ('D'-button)
1565 =item * credit limit
1567 =item * more workflows (save as new, quotation, purchase order)
1569 =item * price sources: little symbols showing better price / better discount
1571 =item * select units in input row?
1573 =item * custom shipto address
1575 =item * language / part translations
1577 =item * access rights
1579 =item * display weights
1585 =item * optional client/user behaviour
1587 (transactions has to be set - department has to be set -
1588 force project if enabled in client config - transport cost reminder)
1592 =head1 KNOWN BUGS AND CAVEATS
1598 Customer discount is not displayed as a valid discount in price source popup
1599 (this might be a bug in price sources)
1601 (I cannot reproduce this (Bernd))
1605 No indication that <shift>-up/down expands/collapses second row.
1609 Inline creation of parts is not currently supported
1613 Table header is not sticky in the scrolling area.
1617 Sorting does not include C<position>, neither does reordering.
1619 This behavior was implemented intentionally. But we can discuss, which behavior
1620 should be implemented.
1624 C<show_multi_items_dialog> does not use the currently inserted string for
1629 The language selected in print or email dialog is not saved when the order is saved.
1633 =head1 To discuss / Nice to have
1639 How to expand/collapse second row. Now it can be done clicking the icon or
1644 Possibility to change longdescription in input row?
1648 Possibility to select PriceSources in input row?
1652 This controller uses a (changed) copy of the template for the PriceSource
1653 dialog. Maybe there could be used one code source.
1657 Rounding-differences between this controller (PriceTaxCalculator) and the old
1658 form. This is not only a problem here, but also in all parts using the PTC.
1659 There exists a ticket and a patch. This patch should be testet.
1663 An indicator, if the actual inputs are saved (like in an
1664 editor or on text processing application).
1668 A warning when leaving the page without saveing unchanged inputs.
1672 Workflows for delivery order and invoice are in the menu "Save", because the
1673 order is saved before opening the new document form. Nevertheless perhaps these
1674 workflow buttons should be put under "Workflows".
1681 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>