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;
13 use SL::Util qw(trim);
19 use SL::DB::PartsGroup;
22 use SL::DB::RecordLink;
24 use SL::Helper::CreatePDF qw(:all);
25 use SL::Helper::PrintOptions;
26 use SL::Helper::ShippedQty;
28 use SL::Controller::Helper::GetModels;
30 use List::Util qw(first);
31 use List::UtilsBy qw(sort_by uniq_by);
32 use List::MoreUtils qw(any none pairwise first_index);
33 use English qw(-no_match_vars);
38 use Rose::Object::MakeMethods::Generic
40 scalar => [ qw(item_ids_to_delete) ],
41 'scalar --get_set_init' => [ qw(order valid_types type cv p multi_items_models all_price_factors) ],
46 __PACKAGE__->run_before('check_auth');
48 __PACKAGE__->run_before('recalc',
49 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice print send_email) ]);
51 __PACKAGE__->run_before('get_unalterable_data',
52 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice print send_email) ]);
62 $self->order->transdate(DateTime->now_local());
63 my $extra_days = $self->{type} eq 'sales_quotation' ? $::instance_conf->get_reqdate_interval :
64 $self->{type} eq 'sales_order' ? $::instance_conf->get_delivery_date_interval : 1;
65 $self->order->reqdate(DateTime->today_local->next_workday(extra_days => $extra_days)) if !$self->order->reqdate;
71 title => $self->get_title_for('add'),
72 %{$self->{template_args}}
76 # edit an existing order
84 # this is to edit an order from an unsaved order object
86 # set item ids to new fake id, to identify them as new items
87 foreach my $item (@{$self->order->items_sorted}) {
88 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
90 # trigger rendering values for second row/longdescription as hidden,
91 # because they are loaded only on demand. So we need to keep the values
93 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
94 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
101 title => $self->get_title_for('edit'),
102 %{$self->{template_args}}
106 # edit a collective order (consisting of one or more existing orders)
107 sub action_edit_collective {
111 my @multi_ids = map {
112 $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1}
113 } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form;
115 # fall back to add if no ids are given
116 if (scalar @multi_ids == 0) {
121 # fall back to save as new if only one id is given
122 if (scalar @multi_ids == 1) {
123 $self->order(SL::DB::Order->new(id => $multi_ids[0])->load);
124 $self->action_save_as_new();
128 # make new order from given orders
129 my @multi_orders = map { SL::DB::Order->new(id => $_)->load } @multi_ids;
130 $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders;
131 $self->order(SL::DB::Order->new_from_multi(\@multi_orders, sort_sources_by => 'transdate'));
133 $self->action_edit();
140 my $errors = $self->delete();
142 if (scalar @{ $errors }) {
143 $self->js->flash('error', $_) foreach @{ $errors };
144 return $self->js->render();
147 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been deleted')
148 : $self->type eq purchase_order_type() ? $::locale->text('The order has been deleted')
149 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been deleted')
150 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been deleted')
152 flash_later('info', $text);
154 my @redirect_params = (
159 $self->redirect_to(@redirect_params);
166 my $errors = $self->save();
168 if (scalar @{ $errors }) {
169 $self->js->flash('error', $_) foreach @{ $errors };
170 return $self->js->render();
173 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
174 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
175 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
176 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
178 flash_later('info', $text);
180 my @redirect_params = (
183 id => $self->order->id,
186 $self->redirect_to(@redirect_params);
189 # save the order as new document an open it for edit
190 sub action_save_as_new {
193 my $order = $self->order;
196 $self->js->flash('error', t8('This object has not been saved yet.'));
197 return $self->js->render();
200 # load order from db to check if values changed
201 my $saved_order = SL::DB::Order->new(id => $order->id)->load;
204 # Lets assign a new number if the user hasn't changed the previous one.
205 # If it has been changed manually then use it as-is.
206 $new_attrs{number} = (trim($order->number) eq $saved_order->number)
208 : trim($order->number);
210 # Clear transdate unless changed
211 $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
212 ? DateTime->today_local
215 # Set new reqdate unless changed
216 if ($order->reqdate == $saved_order->reqdate) {
217 my $extra_days = $self->{type} eq 'sales_quotation' ? $::instance_conf->get_reqdate_interval :
218 $self->{type} eq 'sales_order' ? $::instance_conf->get_delivery_date_interval : 1;
219 $new_attrs{reqdate} = DateTime->today_local->next_workday(extra_days => $extra_days);
221 $new_attrs{reqdate} = $order->reqdate;
225 $new_attrs{employee} = SL::DB::Manager::Employee->current;
227 # Create new record from current one
228 $self->order(SL::DB::Order->new_from($order, destination_type => $order->type, attributes => \%new_attrs));
230 # no linked records on save as new
231 delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
234 $self->action_save();
239 # This is called if "print" is pressed in the print dialog.
240 # If PDF creation was requested and succeeded, the pdf is stored in a session
241 # file and the filename is stored as session value with an unique key. A
242 # javascript function with this key is then called. This function calls the
243 # download action below (action_download_pdf), which offers the file for
248 my $errors = $self->save();
250 if (scalar @{ $errors }) {
251 $self->js->flash('error', $_) foreach @{ $errors };
252 return $self->js->render();
255 $self->js_reset_order_and_item_ids_after_save;
257 my $format = $::form->{print_options}->{format};
258 my $media = $::form->{print_options}->{media};
259 my $formname = $::form->{print_options}->{formname};
260 my $copies = $::form->{print_options}->{copies};
261 my $groupitems = $::form->{print_options}->{groupitems};
263 # only pdf and opendocument by now
264 if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf)) {
265 return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
268 # only screen or printer by now
269 if (none { $media eq $_ } qw(screen printer)) {
270 return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
274 $language = SL::DB::Language->new(id => $::form->{print_options}->{language_id})->load if $::form->{print_options}->{language_id};
276 # create a form for generate_attachment_filename
277 my $form = Form->new;
278 $form->{$self->nr_key()} = $self->order->number;
279 $form->{type} = $self->type;
280 $form->{format} = $format;
281 $form->{formname} = $formname;
282 $form->{language} = '_' . $language->template_code if $language;
283 my $pdf_filename = $form->generate_attachment_filename();
286 my @errors = generate_pdf($self->order, \$pdf, { format => $format,
287 formname => $formname,
288 language => $language,
289 groupitems => $groupitems });
290 if (scalar @errors) {
291 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
294 if ($media eq 'screen') {
296 my $sfile = SL::SessionFile::Random->new(mode => "w");
297 $sfile->fh->print($pdf);
300 my $key = join('_', Time::HiRes::gettimeofday(), int rand 1000000000000);
301 $::auth->set_session_value("Order::print-${key}" => $sfile->file_name);
304 ->run('kivi.Order.download_pdf', $pdf_filename, $key)
305 ->flash('info', t8('The PDF has been created'));
307 } elsif ($media eq 'printer') {
309 my $printer_id = $::form->{print_options}->{printer_id};
310 SL::DB::Printer->new(id => $printer_id)->load->print_document(
315 $self->js->flash('info', t8('The PDF has been printed'));
318 # copy file to webdav folder
319 if ($self->order->number && $::instance_conf->get_webdav_documents) {
320 my $webdav = SL::Webdav->new(
322 number => $self->order->number,
324 my $webdav_file = SL::Webdav::File->new(
326 filename => $pdf_filename,
329 $webdav_file->store(data => \$pdf);
332 $self->js->flash('error', t8('Storing PDF to webdav folder failed: #1', $@));
335 if ($self->order->number && $::instance_conf->get_doc_storage) {
337 SL::File->save(object_id => $self->order->id,
338 object_type => $self->type,
339 mime_type => 'application/pdf',
341 file_type => 'document',
342 file_name => $pdf_filename,
343 file_contents => $pdf);
346 $self->js->flash('error', t8('Storing PDF in storage backend failed: #1', $@));
352 # offer pdf for download
354 # It needs to get the key for the session value to get the pdf file.
355 sub action_download_pdf {
358 my $key = $::form->{key};
359 my $tmp_filename = $::auth->get_session_value("Order::print-${key}");
360 return $self->send_file(
362 type => 'application/pdf',
363 name => $::form->{pdf_filename},
367 # open the email dialog
368 sub action_show_email_dialog {
371 my $cv_method = $self->cv;
373 if (!$self->order->$cv_method) {
374 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'))
379 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
380 $email_form->{to} ||= $self->order->$cv_method->email;
381 $email_form->{cc} = $self->order->$cv_method->cc;
382 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
383 # Todo: get addresses from shipto, if any
385 my $form = Form->new;
386 $form->{$self->nr_key()} = $self->order->number;
387 $form->{formname} = $self->type;
388 $form->{type} = $self->type;
389 $form->{language} = 'de';
390 $form->{format} = 'pdf';
392 $email_form->{subject} = $form->generate_email_subject();
393 $email_form->{attachment_filename} = $form->generate_attachment_filename();
394 $email_form->{message} = $form->generate_email_body();
395 $email_form->{js_send_function} = 'kivi.Order.send_email()';
397 my %files = $self->get_files_for_email_dialog();
398 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
399 email_form => $email_form,
400 show_bcc => $::auth->assert('email_bcc', 'may fail'),
402 is_customer => $self->cv eq 'customer',
406 ->run('kivi.Order.show_email_dialog', $dialog_html)
413 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
414 sub action_send_email {
417 my $errors = $self->save();
419 if (scalar @{ $errors }) {
420 $self->js->run('kivi.Order.close_email_dialog');
421 $self->js->flash('error', $_) foreach @{ $errors };
422 return $self->js->render();
425 $self->js_reset_order_and_item_ids_after_save;
427 my $email_form = delete $::form->{email_form};
428 my %field_names = (to => 'email');
430 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
432 # for Form::cleanup which may be called in Form::send_email
433 $::form->{cwd} = getcwd();
434 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
436 $::form->{$_} = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
437 $::form->{media} = 'email';
439 if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) {
441 $language = SL::DB::Language->new(id => $::form->{print_options}->{language_id})->load if $::form->{print_options}->{language_id};
444 my @errors = generate_pdf($self->order, \$pdf, {media => $::form->{media},
445 format => $::form->{print_options}->{format},
446 formname => $::form->{print_options}->{formname},
447 language => $language,
448 groupitems => $::form->{print_options}->{groupitems}});
449 if (scalar @errors) {
450 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
453 my $sfile = SL::SessionFile::Random->new(mode => "w");
454 $sfile->fh->print($pdf);
457 $::form->{tmpfile} = $sfile->file_name;
458 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
461 $::form->send_email(\%::myconfig, 'pdf');
464 my $intnotes = $self->order->intnotes;
465 $intnotes .= "\n\n" if $self->order->intnotes;
466 $intnotes .= t8('[email]') . "\n";
467 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
468 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
469 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
470 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
471 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
472 $intnotes .= t8('Message') . ": " . $::form->{message};
474 $self->order->update_attributes(intnotes => $intnotes);
477 ->val('#order_intnotes', $intnotes)
478 ->run('kivi.Order.close_email_dialog')
479 ->flash('info', t8('The email has been sent.'))
483 # open the periodic invoices config dialog
485 # If there are values in the form (i.e. dialog was opened before),
486 # then use this values. Create new ones, else.
487 sub action_show_periodic_invoices_config_dialog {
490 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
491 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
492 $config ||= SL::DB::PeriodicInvoicesConfig->new(periodicity => 'm',
493 order_value_periodicity => 'p', # = same as periodicity
494 start_date_as_date => $::form->{transdate_as_date} || $::form->current_date,
495 extend_automatically_by => 12,
497 email_subject => GenericTranslations->get(
498 language_id => $::form->{language_id},
499 translation_type =>"preset_text_periodic_invoices_email_subject"),
500 email_body => GenericTranslations->get(
501 language_id => $::form->{language_id},
502 translation_type =>"preset_text_periodic_invoices_email_body"),
504 $config->periodicity('m') if none { $_ eq $config->periodicity } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
505 $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
507 $::form->get_lists(printers => "ALL_PRINTERS",
508 charts => { key => 'ALL_CHARTS',
509 transdate => 'current_date' });
511 $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
513 if ($::form->{customer_id}) {
514 $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
515 $::form->{email_recipient_invoice_address} = SL::DB::Manager::Customer->find_by(id => $::form->{customer_id})->invoice_mail;
518 $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
520 popup_js_close_function => 'kivi.Order.close_periodic_invoices_config_dialog()',
521 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
526 # assign the values of the periodic invoices config dialog
527 # as yaml in the hidden tag and set the status.
528 sub action_assign_periodic_invoices_config {
531 $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
533 my $config = { active => $::form->{active} ? 1 : 0,
534 terminated => $::form->{terminated} ? 1 : 0,
535 direct_debit => $::form->{direct_debit} ? 1 : 0,
536 periodicity => (any { $_ eq $::form->{periodicity} } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES) ? $::form->{periodicity} : 'm',
537 order_value_periodicity => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
538 start_date_as_date => $::form->{start_date_as_date},
539 end_date_as_date => $::form->{end_date_as_date},
540 first_billing_date_as_date => $::form->{first_billing_date_as_date},
541 print => $::form->{print} ? 1 : 0,
542 printer_id => $::form->{print} ? $::form->{printer_id} * 1 : undef,
543 copies => $::form->{copies} * 1 ? $::form->{copies} : 1,
544 extend_automatically_by => $::form->{extend_automatically_by} * 1 || undef,
545 ar_chart_id => $::form->{ar_chart_id} * 1,
546 send_email => $::form->{send_email} ? 1 : 0,
547 email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
548 email_recipient_address => $::form->{email_recipient_address},
549 email_sender => $::form->{email_sender},
550 email_subject => $::form->{email_subject},
551 email_body => $::form->{email_body},
554 my $periodic_invoices_config = SL::YAML::Dump($config);
556 my $status = $self->get_periodic_invoices_status($config);
559 ->remove('#order_periodic_invoices_config')
560 ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
561 ->run('kivi.Order.close_periodic_invoices_config_dialog')
562 ->html('#periodic_invoices_status', $status)
563 ->flash('info', t8('The periodic invoices config has been assigned.'))
567 sub action_get_has_active_periodic_invoices {
570 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
571 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
573 my $has_active_periodic_invoices =
574 $self->type eq sales_order_type()
577 && (!$config->end_date || ($config->end_date > DateTime->today_local))
578 && $config->get_previous_billed_period_start_date;
580 $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
583 # save the order and redirect to the frontend subroutine for a new
585 sub action_save_and_delivery_order {
588 my $errors = $self->save();
590 if (scalar @{ $errors }) {
591 $self->js->flash('error', $_) foreach @{ $errors };
592 return $self->js->render();
595 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
596 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
597 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
598 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
600 flash_later('info', $text);
602 my @redirect_params = (
603 controller => 'oe.pl',
604 action => 'oe_delivery_order_from_order',
605 id => $self->order->id,
608 $self->redirect_to(@redirect_params);
611 # save the order and redirect to the frontend subroutine for a new
613 sub action_save_and_invoice {
616 my $errors = $self->save();
618 if (scalar @{ $errors }) {
619 $self->js->flash('error', $_) foreach @{ $errors };
620 return $self->js->render();
623 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
624 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
625 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
626 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
628 flash_later('info', $text);
630 my @redirect_params = (
631 controller => 'oe.pl',
632 action => 'oe_invoice_from_order',
633 id => $self->order->id,
636 $self->redirect_to(@redirect_params);
639 # workflow from sales quotation to sales order
640 sub action_sales_order {
641 $_[0]->workflow_sales_or_purchase_order();
644 # workflow from rfq to purchase order
645 sub action_purchase_order {
646 $_[0]->workflow_sales_or_purchase_order();
649 # set form elements in respect to a changed customer or vendor
651 # This action is called on an change of the customer/vendor picker.
652 sub action_customer_vendor_changed {
655 setup_order_from_cv($self->order);
658 my $cv_method = $self->cv;
660 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
661 $self->js->show('#cp_row');
663 $self->js->hide('#cp_row');
666 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
667 $self->js->show('#shipto_row');
669 $self->js->hide('#shipto_row');
672 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
675 ->replaceWith('#order_cp_id', $self->build_contact_select)
676 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
677 ->replaceWith('#business_info_row', $self->build_business_info_row)
678 ->val( '#order_taxzone_id', $self->order->taxzone_id)
679 ->val( '#order_taxincluded', $self->order->taxincluded)
680 ->val( '#order_payment_id', $self->order->payment_id)
681 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
682 ->val( '#order_intnotes', $self->order->intnotes)
683 ->val( '#language_id', $self->order->$cv_method->language_id)
684 ->focus( '#order_' . $self->cv . '_id');
686 $self->js_redisplay_amounts_and_taxes;
690 # open the dialog for customer/vendor details
691 sub action_show_customer_vendor_details_dialog {
694 my $is_customer = 'customer' eq $::form->{vc};
697 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
699 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
702 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
703 $details{discount_as_percent} = $cv->discount_as_percent;
704 $details{creditlimt} = $cv->creditlimit_as_number;
705 $details{business} = $cv->business->description if $cv->business;
706 $details{language} = $cv->language_obj->description if $cv->language_obj;
707 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
708 $details{payment_terms} = $cv->payment->description if $cv->payment;
709 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
711 foreach my $entry (@{ $cv->shipto }) {
712 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
714 foreach my $entry (@{ $cv->contacts }) {
715 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
718 $_[0]->render('common/show_vc_details', { layout => 0 },
719 is_customer => $is_customer,
724 # called if a unit in an existing item row is changed
725 sub action_unit_changed {
728 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
729 my $item = $self->order->items_sorted->[$idx];
731 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
732 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
737 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
738 $self->js_redisplay_line_values;
739 $self->js_redisplay_amounts_and_taxes;
743 # add an item row for a new item entered in the input row
744 sub action_add_item {
747 my $form_attr = $::form->{add_item};
749 return unless $form_attr->{parts_id};
751 my $item = new_item($self->order, $form_attr);
753 $self->order->add_items($item);
757 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
758 my $row_as_html = $self->p->render('order/tabs/_row',
762 ALL_PRICE_FACTORS => $self->all_price_factors
766 ->append('#row_table_id', $row_as_html);
768 if ( $item->part->is_assortment ) {
769 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
770 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
771 my $attr = { parts_id => $assortment_item->parts_id,
772 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
773 unit => $assortment_item->unit,
774 description => $assortment_item->part->description,
776 my $item = new_item($self->order, $attr);
778 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
779 $item->discount(1) unless $assortment_item->charge;
781 $self->order->add_items( $item );
783 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
784 my $row_as_html = $self->p->render('order/tabs/_row',
788 ALL_PRICE_FACTORS => $self->all_price_factors
791 ->append('#row_table_id', $row_as_html);
796 ->val('.add_item_input', '')
797 ->run('kivi.Order.init_row_handlers')
798 ->run('kivi.Order.row_table_scroll_down')
799 ->run('kivi.Order.renumber_positions')
800 ->focus('#add_item_parts_id_name');
802 $self->js_redisplay_amounts_and_taxes;
806 # open the dialog for entering multiple items at once
807 sub action_show_multi_items_dialog {
808 $_[0]->render('order/tabs/_multi_items_dialog', { layout => 0 },
809 all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
812 # update the filter results in the multi item dialog
813 sub action_multi_items_update_result {
816 $::form->{multi_items}->{filter}->{obsolete} = 0;
818 my $count = $_[0]->multi_items_models->count;
821 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
822 $_[0]->render($text, { layout => 0 });
823 } elsif ($count > $max_count) {
824 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
825 $_[0]->render($text, { layout => 0 });
827 my $multi_items = $_[0]->multi_items_models->get;
828 $_[0]->render('order/tabs/_multi_items_result', { layout => 0 },
829 multi_items => $multi_items);
833 # add item rows for multiple items at once
834 sub action_add_multi_items {
837 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_multi_items} };
838 return $self->js->render() unless scalar @form_attr;
841 foreach my $attr (@form_attr) {
842 my $item = new_item($self->order, $attr);
844 if ( $item->part->is_assortment ) {
845 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
846 my $attr = { parts_id => $assortment_item->parts_id,
847 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
848 unit => $assortment_item->unit,
849 description => $assortment_item->part->description,
851 my $item = new_item($self->order, $attr);
853 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
854 $item->discount(1) unless $assortment_item->charge;
859 $self->order->add_items(@items);
863 foreach my $item (@items) {
864 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
865 my $row_as_html = $self->p->render('order/tabs/_row',
869 ALL_PRICE_FACTORS => $self->all_price_factors
872 $self->js->append('#row_table_id', $row_as_html);
876 ->run('kivi.Order.close_multi_items_dialog')
877 ->run('kivi.Order.init_row_handlers')
878 ->run('kivi.Order.row_table_scroll_down')
879 ->run('kivi.Order.renumber_positions')
880 ->focus('#add_item_parts_id_name');
882 $self->js_redisplay_amounts_and_taxes;
886 # recalculate all linetotals, amounts and taxes and redisplay them
887 sub action_recalc_amounts_and_taxes {
892 $self->js_redisplay_line_values;
893 $self->js_redisplay_amounts_and_taxes;
897 # redisplay item rows if they are sorted by an attribute
898 sub action_reorder_items {
902 partnumber => sub { $_[0]->part->partnumber },
903 description => sub { $_[0]->description },
904 qty => sub { $_[0]->qty },
905 sellprice => sub { $_[0]->sellprice },
906 discount => sub { $_[0]->discount },
909 my $method = $sort_keys{$::form->{order_by}};
910 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
911 if ($::form->{sort_dir}) {
912 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
913 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
915 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
918 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
919 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
921 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
925 ->run('kivi.Order.redisplay_items', \@to_sort)
929 # show the popup to choose a price/discount source
930 sub action_price_popup {
933 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
934 my $item = $self->order->items_sorted->[$idx];
936 $self->render_price_dialog($item);
939 # get the longdescription for an item if the dialog to enter/change the
940 # longdescription was opened and the longdescription is empty
942 # If this item is new, get the longdescription from Part.
943 # Otherwise get it from OrderItem.
944 sub action_get_item_longdescription {
947 if ($::form->{item_id}) {
948 $longdescription = SL::DB::OrderItem->new(id => $::form->{item_id})->load->longdescription;
949 } elsif ($::form->{parts_id}) {
950 $longdescription = SL::DB::Part->new(id => $::form->{parts_id})->load->notes;
952 $_[0]->render(\ $longdescription, { type => 'text' });
955 # load the second row for one or more items
957 # This action gets the html code for all items second rows by rendering a template for
958 # the second row and sets the html code via client js.
959 sub action_load_second_rows {
962 $self->recalc() if $self->order->is_sales; # for margin calculation
964 foreach my $item_id (@{ $::form->{item_ids} }) {
965 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
966 my $item = $self->order->items_sorted->[$idx];
968 $self->js_load_second_row($item, $item_id, 0);
971 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
976 sub js_load_second_row {
977 my ($self, $item, $item_id, $do_parse) = @_;
980 # Parse values from form (they are formated while rendering (template)).
981 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
982 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
983 foreach my $var (@{ $item->cvars_by_config }) {
984 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
986 $item->parse_custom_variable_values;
989 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
992 ->html('#second_row_' . $item_id, $row_as_html)
993 ->data('#second_row_' . $item_id, 'loaded', 1);
996 sub js_redisplay_line_values {
999 my $is_sales = $self->order->is_sales;
1001 # sales orders with margins
1006 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1007 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1008 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1009 ]} @{ $self->order->items_sorted };
1013 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1014 ]} @{ $self->order->items_sorted };
1018 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1021 sub js_redisplay_amounts_and_taxes {
1024 if (scalar @{ $self->{taxes} }) {
1025 $self->js->show('#taxincluded_row_id');
1027 $self->js->hide('#taxincluded_row_id');
1030 if ($self->order->taxincluded) {
1031 $self->js->hide('#subtotal_row_id');
1033 $self->js->show('#subtotal_row_id');
1036 if ($self->order->is_sales) {
1037 my $is_neg = $self->order->marge_total < 0;
1039 ->html('#marge_total_id', $::form->format_amount(\%::myconfig, $self->order->marge_total, 2))
1040 ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
1041 ->action_if( $is_neg, 'addClass', '#marge_total_id', 'plus0')
1042 ->action_if( $is_neg, 'addClass', '#marge_percent_id', 'plus0')
1043 ->action_if( $is_neg, 'addClass', '#marge_percent_sign_id', 'plus0')
1044 ->action_if(!$is_neg, 'removeClass', '#marge_total_id', 'plus0')
1045 ->action_if(!$is_neg, 'removeClass', '#marge_percent_id', 'plus0')
1046 ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
1050 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
1051 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
1052 ->remove('.tax_row')
1053 ->insertBefore($self->build_tax_rows, '#amount_row_id');
1056 sub js_reset_order_and_item_ids_after_save {
1060 ->val('#id', $self->order->id)
1061 ->val('#converted_from_oe_id', '')
1062 ->val('#order_' . $self->nr_key(), $self->order->number);
1065 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1066 next if !$self->order->items_sorted->[$idx]->id;
1067 next if $form_item_id !~ m{^new};
1069 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1070 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1071 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1074 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1081 sub init_valid_types {
1082 [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
1088 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1089 die "Not a valid type for order";
1092 $self->type($::form->{type});
1098 my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer'
1099 : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
1100 : die "Not a valid type for order";
1113 # model used to filter/display the parts in the multi-items dialog
1114 sub init_multi_items_models {
1115 SL::Controller::Helper::GetModels->new(
1116 controller => $_[0],
1118 with_objects => [ qw(unit_obj) ],
1119 disable_plugin => 'paginated',
1120 source => $::form->{multi_items},
1126 partnumber => t8('Partnumber'),
1127 description => t8('Description')}
1131 sub init_all_price_factors {
1132 SL::DB::Manager::PriceFactor->get_all;
1138 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
1140 my $right = $right_for->{ $self->type };
1141 $right ||= 'DOES_NOT_EXIST';
1143 $::auth->assert($right);
1146 # build the selection box for contacts
1148 # Needed, if customer/vendor changed.
1149 sub build_contact_select {
1152 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1153 value_key => 'cp_id',
1154 title_key => 'full_name_dep',
1155 default => $self->order->cp_id,
1157 style => 'width: 300px',
1161 # build the selection box for shiptos
1163 # Needed, if customer/vendor changed.
1164 sub build_shipto_select {
1167 select_tag('order.shipto_id', [ $self->order->{$self->cv}->shipto ],
1168 value_key => 'shipto_id',
1169 title_key => 'displayable_id',
1170 default => $self->order->shipto_id,
1172 style => 'width: 300px',
1176 # render the info line for business
1178 # Needed, if customer/vendor changed.
1179 sub build_business_info_row
1181 $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
1184 # build the rows for displaying taxes
1186 # Called if amounts where recalculated and redisplayed.
1187 sub build_tax_rows {
1191 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
1192 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
1194 return $rows_as_html;
1198 sub render_price_dialog {
1199 my ($self, $record_item) = @_;
1201 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1205 'kivi.io.price_chooser_dialog',
1206 t8('Available Prices'),
1207 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1212 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1213 # $self->js->show('#dialog_flash_error');
1222 return if !$::form->{id};
1224 $self->order(SL::DB::Order->new(id => $::form->{id})->load);
1227 # load or create a new order object
1229 # And assign changes from the form to this object.
1230 # If the order is loaded from db, check if items are deleted in the form,
1231 # remove them form the object and collect them for removing from db on saving.
1232 # Then create/update items from form (via make_item) and add them.
1236 # add_items adds items to an order with no items for saving, but they cannot
1237 # be retrieved via items until the order is saved. Adding empty items to new
1238 # order here solves this problem.
1240 $order = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1241 $order ||= SL::DB::Order->new(orderitems => [],
1242 quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())));
1244 my $cv_id_method = $self->cv . '_id';
1245 if (!$::form->{id} && $::form->{$cv_id_method}) {
1246 $order->$cv_id_method($::form->{$cv_id_method});
1247 setup_order_from_cv($order);
1250 my $form_orderitems = delete $::form->{order}->{orderitems};
1251 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1253 $order->assign_attributes(%{$::form->{order}});
1255 if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) {
1256 my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
1257 $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
1260 # remove deleted items
1261 $self->item_ids_to_delete([]);
1262 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1263 my $item = $order->orderitems->[$idx];
1264 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1265 splice @{$order->orderitems}, $idx, 1;
1266 push @{$self->item_ids_to_delete}, $item->id;
1272 foreach my $form_attr (@{$form_orderitems}) {
1273 my $item = make_item($order, $form_attr);
1274 $item->position($pos);
1278 $order->add_items(grep {!$_->id} @items);
1283 # create or update items from form
1285 # Make item objects from form values. For items already existing read from db.
1286 # Create a new item else. And assign attributes.
1288 my ($record, $attr) = @_;
1291 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1293 my $is_new = !$item;
1295 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1296 # they cannot be retrieved via custom_variables until the order/orderitem is
1297 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1298 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1300 $item->assign_attributes(%$attr);
1301 $item->longdescription($item->part->notes) if $is_new && !defined $attr->{longdescription};
1302 $item->project_id($record->globalproject_id) if $is_new && !defined $attr->{project_id};
1303 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if $is_new && !defined $attr->{lastcost_as_number};
1310 # This is used to add one item
1312 my ($record, $attr) = @_;
1314 my $item = SL::DB::OrderItem->new;
1316 # Remove attributes where the user left or set the inputs empty.
1317 # So these attributes will be undefined and we can distinguish them
1318 # from zero later on.
1319 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1320 delete $attr->{$_} if $attr->{$_} eq '';
1323 $item->assign_attributes(%$attr);
1325 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1326 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1328 $item->unit($part->unit) if !$item->unit;
1331 if ( $part->is_assortment ) {
1332 # add assortment items with price 0, as the components carry the price
1333 $price_src = $price_source->price_from_source("");
1334 $price_src->price(0);
1335 } elsif (defined $item->sellprice) {
1336 $price_src = $price_source->price_from_source("");
1337 $price_src->price($item->sellprice);
1339 $price_src = $price_source->best_price
1340 ? $price_source->best_price
1341 : $price_source->price_from_source("");
1342 $price_src->price(0) if !$price_source->best_price;
1346 if (defined $item->discount) {
1347 $discount_src = $price_source->discount_from_source("");
1348 $discount_src->discount($item->discount);
1350 $discount_src = $price_source->best_discount
1351 ? $price_source->best_discount
1352 : $price_source->discount_from_source("");
1353 $discount_src->discount(0) if !$price_source->best_discount;
1357 $new_attr{part} = $part;
1358 $new_attr{description} = $part->description if ! $item->description;
1359 $new_attr{qty} = 1.0 if ! $item->qty;
1360 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1361 $new_attr{sellprice} = $price_src->price;
1362 $new_attr{discount} = $discount_src->discount;
1363 $new_attr{active_price_source} = $price_src;
1364 $new_attr{active_discount_source} = $discount_src;
1365 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1366 $new_attr{project_id} = $record->globalproject_id;
1367 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1369 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1370 # they cannot be retrieved via custom_variables until the order/orderitem is
1371 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1372 $new_attr{custom_variables} = [];
1374 $item->assign_attributes(%new_attr);
1379 sub setup_order_from_cv {
1382 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id));
1384 $order->intnotes($order->customervendor->notes);
1386 if ($order->is_sales) {
1387 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1388 $order->taxincluded(defined($order->customer->taxincluded_checked)
1389 ? $order->customer->taxincluded_checked
1390 : $::myconfig{taxincluded_checked});
1395 # recalculate prices and taxes
1397 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1401 # bb: todo: currency later
1402 $self->order->currency_id($::instance_conf->get_currency_id());
1404 my %pat = $self->order->calculate_prices_and_taxes();
1405 $self->{taxes} = [];
1406 foreach my $tax_chart_id (keys %{ $pat{taxes} }) {
1407 my $tax = SL::DB::Manager::Tax->find_by(chart_id => $tax_chart_id);
1409 my @amount_keys = grep { $pat{amounts}->{$_}->{tax_id} == $tax->id } keys %{ $pat{amounts} };
1410 push(@{ $self->{taxes} }, { amount => $pat{taxes}->{$tax_chart_id},
1411 netamount => $pat{amounts}->{$amount_keys[0]}->{amount},
1415 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items_sorted}, @{$pat{items}};
1418 # get data for saving, printing, ..., that is not changed in the form
1420 # Only cvars for now.
1421 sub get_unalterable_data {
1424 foreach my $item (@{ $self->order->items }) {
1425 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1426 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1427 foreach my $var (@{ $item->cvars_by_config }) {
1428 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1430 $item->parse_custom_variable_values;
1436 # And remove related files in the spool directory
1441 my $db = $self->order->db;
1443 $db->with_transaction(
1445 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1446 $self->order->delete;
1447 my $spool = $::lx_office_conf{paths}->{spool};
1448 unlink map { "$spool/$_" } @spoolfiles if $spool;
1451 }) || push(@{$errors}, $db->error);
1458 # And delete items that are deleted in the form.
1463 my $db = $self->order->db;
1465 $db->with_transaction(sub {
1466 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1467 $self->order->save(cascade => 1);
1470 if ($::form->{converted_from_oe_id}) {
1471 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1472 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1473 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1474 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
1475 $src->link_to_record($self->order);
1477 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1479 foreach (@{ $self->order->items_sorted }) {
1480 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1482 SL::DB::RecordLink->new(from_table => 'orderitems',
1483 from_id => $from_id,
1484 to_table => 'orderitems',
1492 }) || push(@{$errors}, $db->error);
1497 sub workflow_sales_or_purchase_order {
1501 my $errors = $self->save();
1503 if (scalar @{ $errors }) {
1504 $self->js->flash('error', $_) foreach @{ $errors };
1505 return $self->js->render();
1508 my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type()
1509 : $::form->{type} eq request_quotation_type() ? purchase_order_type()
1510 : $::form->{type} eq purchase_order_type() ? sales_order_type()
1511 : $::form->{type} eq sales_order_type() ? purchase_order_type()
1514 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1515 $self->{converted_from_oe_id} = delete $::form->{id};
1517 # set item ids to new fake id, to identify them as new items
1518 foreach my $item (@{$self->order->items_sorted}) {
1519 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1523 $::form->{type} = $destination_type;
1524 $self->type($self->init_type);
1525 $self->cv ($self->init_cv);
1529 $self->get_unalterable_data();
1530 $self->pre_render();
1532 # trigger rendering values for second row/longdescription as hidden,
1533 # because they are loaded only on demand. So we need to keep the values
1535 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1536 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
1540 title => $self->get_title_for('edit'),
1541 %{$self->{template_args}}
1549 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1550 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1551 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1554 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1557 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1559 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1560 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1561 $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
1562 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1564 my $print_form = Form->new('');
1565 $print_form->{type} = $self->type;
1566 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1567 $print_form->{languages} = SL::DB::Manager::Language->get_all_sorted;
1568 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1569 form => $print_form,
1570 options => {dialog_name_prefix => 'print_options.',
1574 no_opendocument => 0,
1578 foreach my $item (@{$self->order->orderitems}) {
1579 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1580 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1581 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1584 if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
1585 # calculate shipped qtys here to prevent calling calculate for every item via the items method
1586 SL::Helper::ShippedQty->new->calculate($self->order)->write_to_objects;
1589 if ($self->order->number && $::instance_conf->get_webdav) {
1590 my $webdav = SL::Webdav->new(
1591 type => $self->type,
1592 number => $self->order->number,
1594 my @all_objects = $webdav->get_all_objects;
1595 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1597 link => File::Spec->catfile($_->full_filedescriptor),
1601 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery edit_periodic_invoices_config calculate_qty);
1602 $self->setup_edit_action_bar;
1605 sub setup_edit_action_bar {
1606 my ($self, %params) = @_;
1608 my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
1609 || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
1610 || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
1612 for my $bar ($::request->layout->get('actionbar')) {
1617 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1618 $::instance_conf->get_order_warn_no_deliverydate,
1620 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1624 call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1625 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1626 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1628 ], # end of combobox "Save"
1635 t8('Save and Sales Order'),
1636 submit => [ '#order_form', { action => "Order/sales_order" } ],
1637 only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
1638 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1641 t8('Save and Purchase Order'),
1642 submit => [ '#order_form', { action => "Order/purchase_order" } ],
1643 only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
1644 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1647 t8('Save and Delivery Order'),
1648 call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1649 $::instance_conf->get_order_warn_no_deliverydate,
1651 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1652 only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type()))
1655 t8('Save and Invoice'),
1656 call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1657 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1659 ], # end of combobox "Workflow"
1666 t8('Save and print'),
1667 call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts ],
1670 t8('Save and E-mail'),
1671 call => [ 'kivi.Order.email', $::instance_conf->get_order_warn_duplicate_parts ],
1674 t8('Download attachments of all parts'),
1675 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1676 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1677 only_if => $::instance_conf->get_doc_storage,
1679 ], # end of combobox "Export"
1683 call => [ 'kivi.Order.delete_order' ],
1684 confirm => $::locale->text('Do you really want to delete this object?'),
1685 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1686 only_if => $deletion_allowed,
1693 my ($order, $pdf_ref, $params) = @_;
1697 my $print_form = Form->new('');
1698 $print_form->{type} = $order->type;
1699 $print_form->{formname} = $params->{formname} || $order->type;
1700 $print_form->{format} = $params->{format} || 'pdf';
1701 $print_form->{media} = $params->{media} || 'file';
1702 $print_form->{groupitems} = $params->{groupitems};
1703 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1705 $order->language($params->{language});
1706 $order->flatten_to_form($print_form, format_amounts => 1);
1710 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
1711 $template_ext = 'odt';
1712 $template_type = 'OpenDocument';
1715 # search for the template
1716 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1717 name => $print_form->{formname},
1718 extension => $template_ext,
1719 email => $print_form->{media} eq 'email',
1720 language => $params->{language},
1721 printer_id => $print_form->{printer_id}, # todo
1724 if (!defined $template_file) {
1725 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);
1728 return @errors if scalar @errors;
1730 $print_form->throw_on_error(sub {
1732 $print_form->prepare_for_printing;
1734 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1735 format => $print_form->{format},
1736 template_type => $template_type,
1737 template => $template_file,
1738 variables => $print_form,
1739 variable_content_types => {
1740 longdescription => 'html',
1741 partnotes => 'html',
1746 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
1752 sub get_files_for_email_dialog {
1755 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
1757 return %files if !$::instance_conf->get_doc_storage;
1759 if ($self->order->id) {
1760 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
1761 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
1762 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
1766 uniq_by { $_->{id} }
1768 +{ id => $_->part->id,
1769 partnumber => $_->part->partnumber }
1770 } @{$self->order->items_sorted};
1772 foreach my $part (@parts) {
1773 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
1774 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
1777 foreach my $key (keys %files) {
1778 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
1784 sub make_periodic_invoices_config_from_yaml {
1785 my ($yaml_config) = @_;
1787 return if !$yaml_config;
1788 my $attr = SL::YAML::Load($yaml_config);
1789 return if 'HASH' ne ref $attr;
1790 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
1794 sub get_periodic_invoices_status {
1795 my ($self, $config) = @_;
1797 return if $self->type ne sales_order_type();
1798 return t8('not configured') if !$config;
1800 my $active = ('HASH' eq ref $config) ? $config->{active}
1801 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
1802 : die "Cannot get status of periodic invoices config";
1804 return $active ? t8('active') : t8('inactive');
1808 my ($self, $action) = @_;
1810 return '' if none { lc($action)} qw(add edit);
1813 # $::locale->text("Add Sales Order");
1814 # $::locale->text("Add Purchase Order");
1815 # $::locale->text("Add Quotation");
1816 # $::locale->text("Add Request for Quotation");
1817 # $::locale->text("Edit Sales Order");
1818 # $::locale->text("Edit Purchase Order");
1819 # $::locale->text("Edit Quotation");
1820 # $::locale->text("Edit Request for Quotation");
1822 $action = ucfirst(lc($action));
1823 return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order")
1824 : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order")
1825 : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation")
1826 : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
1830 sub sales_order_type {
1834 sub purchase_order_type {
1838 sub sales_quotation_type {
1842 sub request_quotation_type {
1843 'request_quotation';
1847 return $_[0]->type eq sales_order_type() ? 'ordnumber'
1848 : $_[0]->type eq purchase_order_type() ? 'ordnumber'
1849 : $_[0]->type eq sales_quotation_type() ? 'quonumber'
1850 : $_[0]->type eq request_quotation_type() ? 'quonumber'
1862 SL::Controller::Order - controller for orders
1866 This is a new form to enter orders, completely rewritten with the use
1867 of controller and java script techniques.
1869 The aim is to provide the user a better expirience and a faster flow
1870 of work. Also the code should be more readable, more reliable and
1879 One input row, so that input happens every time at the same place.
1883 Use of pickers where possible.
1887 Possibility to enter more than one item at once.
1891 Item list in a scrollable area, so that the workflow buttons stay at
1896 Reordering item rows with drag and drop is possible. Sorting item rows is
1897 possible (by partnumber, description, qty, sellprice and discount for now).
1901 No C<update> is necessary. All entries and calculations are managed
1902 with ajax-calls and the page does only reload on C<save>.
1906 User can see changes immediately, because of the use of java script
1917 =item * C<SL/Controller/Order.pm>
1921 =item * C<template/webpages/order/form.html>
1925 =item * C<template/webpages/order/tabs/basic_data.html>
1927 Main tab for basic_data.
1929 This is the only tab here for now. "linked records" and "webdav" tabs are
1930 reused from generic code.
1934 =item * C<template/webpages/order/tabs/_business_info_row.html>
1936 For displaying information on business type
1938 =item * C<template/webpages/order/tabs/_item_input.html>
1940 The input line for items
1942 =item * C<template/webpages/order/tabs/_row.html>
1944 One row for already entered items
1946 =item * C<template/webpages/order/tabs/_tax_row.html>
1948 Displaying tax information
1950 =item * C<template/webpages/order/tabs/_multi_items_dialog.html>
1952 Dialog for entering more than one item at once
1954 =item * C<template/webpages/order/tabs/_multi_items_result.html>
1956 Results for the filter in the multi items dialog
1958 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
1960 Dialog for selecting price and discount sources
1964 =item * C<js/kivi.Order.js>
1966 java script functions
1978 =item * credit limit
1980 =item * more workflows (quotation, rfq)
1982 =item * price sources: little symbols showing better price / better discount
1984 =item * select units in input row?
1986 =item * custom shipto address
1988 =item * check for direct delivery (workflow sales order -> purchase order)
1990 =item * language / part translations
1992 =item * access rights
1994 =item * display weights
2000 =item * optional client/user behaviour
2002 (transactions has to be set - department has to be set -
2003 force project if enabled in client config - transport cost reminder)
2007 =head1 KNOWN BUGS AND CAVEATS
2013 Customer discount is not displayed as a valid discount in price source popup
2014 (this might be a bug in price sources)
2016 (I cannot reproduce this (Bernd))
2020 No indication that <shift>-up/down expands/collapses second row.
2024 Inline creation of parts is not currently supported
2028 Table header is not sticky in the scrolling area.
2032 Sorting does not include C<position>, neither does reordering.
2034 This behavior was implemented intentionally. But we can discuss, which behavior
2035 should be implemented.
2039 C<show_multi_items_dialog> does not use the currently inserted string for
2044 The language selected in print or email dialog is not saved when the order is saved.
2048 =head1 To discuss / Nice to have
2054 How to expand/collapse second row. Now it can be done clicking the icon or
2059 Possibility to change longdescription in input row?
2063 Possibility to select PriceSources in input row?
2067 This controller uses a (changed) copy of the template for the PriceSource
2068 dialog. Maybe there could be used one code source.
2072 Rounding-differences between this controller (PriceTaxCalculator) and the old
2073 form. This is not only a problem here, but also in all parts using the PTC.
2074 There exists a ticket and a patch. This patch should be testet.
2078 An indicator, if the actual inputs are saved (like in an
2079 editor or on text processing application).
2083 A warning when leaving the page without saveing unchanged inputs.
2090 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>