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;
14 use SL::Util qw(trim);
20 use SL::DB::PartsGroup;
23 use SL::DB::RecordLink;
25 use SL::Helper::CreatePDF qw(:all);
26 use SL::Helper::PrintOptions;
27 use SL::Helper::ShippedQty;
28 use SL::Helper::UserPreferences::PositionsScrollbar;
30 use SL::Controller::Helper::GetModels;
32 use List::Util qw(first);
33 use List::UtilsBy qw(sort_by uniq_by);
34 use List::MoreUtils qw(any none pairwise first_index);
35 use English qw(-no_match_vars);
40 use Rose::Object::MakeMethods::Generic
42 scalar => [ qw(item_ids_to_delete) ],
43 'scalar --get_set_init' => [ qw(order valid_types type cv p multi_items_models all_price_factors search_cvpartnumber) ],
48 __PACKAGE__->run_before('check_auth');
50 __PACKAGE__->run_before('recalc',
51 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice print send_email) ]);
53 __PACKAGE__->run_before('get_unalterable_data',
54 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice print send_email) ]);
64 $self->order->transdate(DateTime->now_local());
65 my $extra_days = $self->{type} eq 'sales_quotation' ? $::instance_conf->get_reqdate_interval :
66 $self->{type} eq 'sales_order' ? $::instance_conf->get_delivery_date_interval : 1;
67 $self->order->reqdate(DateTime->today_local->next_workday(extra_days => $extra_days)) if !$self->order->reqdate;
73 title => $self->get_title_for('add'),
74 %{$self->{template_args}}
78 # edit an existing order
86 # this is to edit an order from an unsaved order object
88 # set item ids to new fake id, to identify them as new items
89 foreach my $item (@{$self->order->items_sorted}) {
90 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
92 # trigger rendering values for second row/longdescription as hidden,
93 # because they are loaded only on demand. So we need to keep the values
95 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
96 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
103 title => $self->get_title_for('edit'),
104 %{$self->{template_args}}
108 # edit a collective order (consisting of one or more existing orders)
109 sub action_edit_collective {
113 my @multi_ids = map {
114 $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1}
115 } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form;
117 # fall back to add if no ids are given
118 if (scalar @multi_ids == 0) {
123 # fall back to save as new if only one id is given
124 if (scalar @multi_ids == 1) {
125 $self->order(SL::DB::Order->new(id => $multi_ids[0])->load);
126 $self->action_save_as_new();
130 # make new order from given orders
131 my @multi_orders = map { SL::DB::Order->new(id => $_)->load } @multi_ids;
132 $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders;
133 $self->order(SL::DB::Order->new_from_multi(\@multi_orders, sort_sources_by => 'transdate'));
135 $self->action_edit();
142 my $errors = $self->delete();
144 if (scalar @{ $errors }) {
145 $self->js->flash('error', $_) foreach @{ $errors };
146 return $self->js->render();
149 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been deleted')
150 : $self->type eq purchase_order_type() ? $::locale->text('The order has been deleted')
151 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been deleted')
152 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been deleted')
154 flash_later('info', $text);
156 my @redirect_params = (
161 $self->redirect_to(@redirect_params);
168 my $errors = $self->save();
170 if (scalar @{ $errors }) {
171 $self->js->flash('error', $_) foreach @{ $errors };
172 return $self->js->render();
175 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
176 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
177 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
178 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
180 flash_later('info', $text);
182 my @redirect_params = (
185 id => $self->order->id,
188 $self->redirect_to(@redirect_params);
191 # save the order as new document an open it for edit
192 sub action_save_as_new {
195 my $order = $self->order;
198 $self->js->flash('error', t8('This object has not been saved yet.'));
199 return $self->js->render();
202 # load order from db to check if values changed
203 my $saved_order = SL::DB::Order->new(id => $order->id)->load;
206 # Lets assign a new number if the user hasn't changed the previous one.
207 # If it has been changed manually then use it as-is.
208 $new_attrs{number} = (trim($order->number) eq $saved_order->number)
210 : trim($order->number);
212 # Clear transdate unless changed
213 $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
214 ? DateTime->today_local
217 # Set new reqdate unless changed
218 if ($order->reqdate == $saved_order->reqdate) {
219 my $extra_days = $self->{type} eq 'sales_quotation' ? $::instance_conf->get_reqdate_interval :
220 $self->{type} eq 'sales_order' ? $::instance_conf->get_delivery_date_interval : 1;
221 $new_attrs{reqdate} = DateTime->today_local->next_workday(extra_days => $extra_days);
223 $new_attrs{reqdate} = $order->reqdate;
227 $new_attrs{employee} = SL::DB::Manager::Employee->current;
229 # Create new record from current one
230 $self->order(SL::DB::Order->new_from($order, destination_type => $order->type, attributes => \%new_attrs));
232 # no linked records on save as new
233 delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
236 $self->action_save();
241 # This is called if "print" is pressed in the print dialog.
242 # If PDF creation was requested and succeeded, the pdf is stored in a session
243 # file and the filename is stored as session value with an unique key. A
244 # javascript function with this key is then called. This function calls the
245 # download action below (action_download_pdf), which offers the file for
250 my $errors = $self->save();
252 if (scalar @{ $errors }) {
253 $self->js->flash('error', $_) foreach @{ $errors };
254 return $self->js->render();
257 $self->js_reset_order_and_item_ids_after_save;
259 my $format = $::form->{print_options}->{format};
260 my $media = $::form->{print_options}->{media};
261 my $formname = $::form->{print_options}->{formname};
262 my $copies = $::form->{print_options}->{copies};
263 my $groupitems = $::form->{print_options}->{groupitems};
265 # only pdf and opendocument by now
266 if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf)) {
267 return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
270 # only screen or printer by now
271 if (none { $media eq $_ } qw(screen printer)) {
272 return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
276 $language = SL::DB::Language->new(id => $::form->{print_options}->{language_id})->load if $::form->{print_options}->{language_id};
278 # create a form for generate_attachment_filename
279 my $form = Form->new;
280 $form->{$self->nr_key()} = $self->order->number;
281 $form->{type} = $self->type;
282 $form->{format} = $format;
283 $form->{formname} = $formname;
284 $form->{language} = '_' . $language->template_code if $language;
285 my $pdf_filename = $form->generate_attachment_filename();
288 my @errors = generate_pdf($self->order, \$pdf, { format => $format,
289 formname => $formname,
290 language => $language,
291 groupitems => $groupitems });
292 if (scalar @errors) {
293 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
296 if ($media eq 'screen') {
298 my $sfile = SL::SessionFile::Random->new(mode => "w");
299 $sfile->fh->print($pdf);
302 my $key = join('_', Time::HiRes::gettimeofday(), int rand 1000000000000);
303 $::auth->set_session_value("Order::print-${key}" => $sfile->file_name);
306 ->run('kivi.Order.download_pdf', $pdf_filename, $key)
307 ->flash('info', t8('The PDF has been created'));
309 } elsif ($media eq 'printer') {
311 my $printer_id = $::form->{print_options}->{printer_id};
312 SL::DB::Printer->new(id => $printer_id)->load->print_document(
317 $self->js->flash('info', t8('The PDF has been printed'));
320 # copy file to webdav folder
321 if ($self->order->number && $::instance_conf->get_webdav_documents) {
322 my $webdav = SL::Webdav->new(
324 number => $self->order->number,
326 my $webdav_file = SL::Webdav::File->new(
328 filename => $pdf_filename,
331 $webdav_file->store(data => \$pdf);
334 $self->js->flash('error', t8('Storing PDF to webdav folder failed: #1', $@));
337 if ($self->order->number && $::instance_conf->get_doc_storage) {
339 SL::File->save(object_id => $self->order->id,
340 object_type => $self->type,
341 mime_type => 'application/pdf',
343 file_type => 'document',
344 file_name => $pdf_filename,
345 file_contents => $pdf);
348 $self->js->flash('error', t8('Storing PDF in storage backend failed: #1', $@));
354 # offer pdf for download
356 # It needs to get the key for the session value to get the pdf file.
357 sub action_download_pdf {
360 my $key = $::form->{key};
361 my $tmp_filename = $::auth->get_session_value("Order::print-${key}");
362 return $self->send_file(
364 type => SL::MIME->mime_type_from_ext($::form->{pdf_filename}),
365 name => $::form->{pdf_filename},
369 # open the email dialog
370 sub action_show_email_dialog {
373 my $cv_method = $self->cv;
375 if (!$self->order->$cv_method) {
376 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'))
381 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
382 $email_form->{to} ||= $self->order->$cv_method->email;
383 $email_form->{cc} = $self->order->$cv_method->cc;
384 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
385 # Todo: get addresses from shipto, if any
387 my $form = Form->new;
388 $form->{$self->nr_key()} = $self->order->number;
389 $form->{formname} = $self->type;
390 $form->{type} = $self->type;
391 $form->{language} = 'de';
392 $form->{format} = 'pdf';
394 $email_form->{subject} = $form->generate_email_subject();
395 $email_form->{attachment_filename} = $form->generate_attachment_filename();
396 $email_form->{message} = $form->generate_email_body();
397 $email_form->{js_send_function} = 'kivi.Order.send_email()';
399 my %files = $self->get_files_for_email_dialog();
400 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
401 email_form => $email_form,
402 show_bcc => $::auth->assert('email_bcc', 'may fail'),
404 is_customer => $self->cv eq 'customer',
408 ->run('kivi.Order.show_email_dialog', $dialog_html)
415 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
416 sub action_send_email {
419 my $errors = $self->save();
421 if (scalar @{ $errors }) {
422 $self->js->run('kivi.Order.close_email_dialog');
423 $self->js->flash('error', $_) foreach @{ $errors };
424 return $self->js->render();
427 $self->js_reset_order_and_item_ids_after_save;
429 my $email_form = delete $::form->{email_form};
430 my %field_names = (to => 'email');
432 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
434 # for Form::cleanup which may be called in Form::send_email
435 $::form->{cwd} = getcwd();
436 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
438 $::form->{$_} = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
439 $::form->{media} = 'email';
441 if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) {
443 $language = SL::DB::Language->new(id => $::form->{print_options}->{language_id})->load if $::form->{print_options}->{language_id};
446 my @errors = generate_pdf($self->order, \$pdf, {media => $::form->{media},
447 format => $::form->{print_options}->{format},
448 formname => $::form->{print_options}->{formname},
449 language => $language,
450 groupitems => $::form->{print_options}->{groupitems}});
451 if (scalar @errors) {
452 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
455 my $sfile = SL::SessionFile::Random->new(mode => "w");
456 $sfile->fh->print($pdf);
459 $::form->{tmpfile} = $sfile->file_name;
460 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
463 $::form->send_email(\%::myconfig, 'pdf');
466 my $intnotes = $self->order->intnotes;
467 $intnotes .= "\n\n" if $self->order->intnotes;
468 $intnotes .= t8('[email]') . "\n";
469 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
470 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
471 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
472 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
473 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
474 $intnotes .= t8('Message') . ": " . $::form->{message};
476 $self->order->update_attributes(intnotes => $intnotes);
479 ->val('#order_intnotes', $intnotes)
480 ->run('kivi.Order.close_email_dialog')
481 ->flash('info', t8('The email has been sent.'))
485 # open the periodic invoices config dialog
487 # If there are values in the form (i.e. dialog was opened before),
488 # then use this values. Create new ones, else.
489 sub action_show_periodic_invoices_config_dialog {
492 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
493 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
494 $config ||= SL::DB::PeriodicInvoicesConfig->new(periodicity => 'm',
495 order_value_periodicity => 'p', # = same as periodicity
496 start_date_as_date => $::form->{transdate_as_date} || $::form->current_date,
497 extend_automatically_by => 12,
499 email_subject => GenericTranslations->get(
500 language_id => $::form->{language_id},
501 translation_type =>"preset_text_periodic_invoices_email_subject"),
502 email_body => GenericTranslations->get(
503 language_id => $::form->{language_id},
504 translation_type =>"preset_text_periodic_invoices_email_body"),
506 $config->periodicity('m') if none { $_ eq $config->periodicity } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
507 $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
509 $::form->get_lists(printers => "ALL_PRINTERS",
510 charts => { key => 'ALL_CHARTS',
511 transdate => 'current_date' });
513 $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
515 if ($::form->{customer_id}) {
516 $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
517 $::form->{email_recipient_invoice_address} = SL::DB::Manager::Customer->find_by(id => $::form->{customer_id})->invoice_mail;
520 $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
522 popup_js_close_function => 'kivi.Order.close_periodic_invoices_config_dialog()',
523 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
528 # assign the values of the periodic invoices config dialog
529 # as yaml in the hidden tag and set the status.
530 sub action_assign_periodic_invoices_config {
533 $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
535 my $config = { active => $::form->{active} ? 1 : 0,
536 terminated => $::form->{terminated} ? 1 : 0,
537 direct_debit => $::form->{direct_debit} ? 1 : 0,
538 periodicity => (any { $_ eq $::form->{periodicity} } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES) ? $::form->{periodicity} : 'm',
539 order_value_periodicity => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
540 start_date_as_date => $::form->{start_date_as_date},
541 end_date_as_date => $::form->{end_date_as_date},
542 first_billing_date_as_date => $::form->{first_billing_date_as_date},
543 print => $::form->{print} ? 1 : 0,
544 printer_id => $::form->{print} ? $::form->{printer_id} * 1 : undef,
545 copies => $::form->{copies} * 1 ? $::form->{copies} : 1,
546 extend_automatically_by => $::form->{extend_automatically_by} * 1 || undef,
547 ar_chart_id => $::form->{ar_chart_id} * 1,
548 send_email => $::form->{send_email} ? 1 : 0,
549 email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
550 email_recipient_address => $::form->{email_recipient_address},
551 email_sender => $::form->{email_sender},
552 email_subject => $::form->{email_subject},
553 email_body => $::form->{email_body},
556 my $periodic_invoices_config = SL::YAML::Dump($config);
558 my $status = $self->get_periodic_invoices_status($config);
561 ->remove('#order_periodic_invoices_config')
562 ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
563 ->run('kivi.Order.close_periodic_invoices_config_dialog')
564 ->html('#periodic_invoices_status', $status)
565 ->flash('info', t8('The periodic invoices config has been assigned.'))
569 sub action_get_has_active_periodic_invoices {
572 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
573 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
575 my $has_active_periodic_invoices =
576 $self->type eq sales_order_type()
579 && (!$config->end_date || ($config->end_date > DateTime->today_local))
580 && $config->get_previous_billed_period_start_date;
582 $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
585 # save the order and redirect to the frontend subroutine for a new
587 sub action_save_and_delivery_order {
590 my $errors = $self->save();
592 if (scalar @{ $errors }) {
593 $self->js->flash('error', $_) foreach @{ $errors };
594 return $self->js->render();
597 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
598 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
599 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
600 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
602 flash_later('info', $text);
604 my @redirect_params = (
605 controller => 'oe.pl',
606 action => 'oe_delivery_order_from_order',
607 id => $self->order->id,
610 $self->redirect_to(@redirect_params);
613 # save the order and redirect to the frontend subroutine for a new
615 sub action_save_and_invoice {
618 my $errors = $self->save();
620 if (scalar @{ $errors }) {
621 $self->js->flash('error', $_) foreach @{ $errors };
622 return $self->js->render();
625 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
626 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
627 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
628 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
630 flash_later('info', $text);
632 my @redirect_params = (
633 controller => 'oe.pl',
634 action => 'oe_invoice_from_order',
635 id => $self->order->id,
638 $self->redirect_to(@redirect_params);
641 # workflow from sales quotation to sales order
642 sub action_sales_order {
643 $_[0]->workflow_sales_or_purchase_order();
646 # workflow from rfq to purchase order
647 sub action_purchase_order {
648 $_[0]->workflow_sales_or_purchase_order();
651 # set form elements in respect to a changed customer or vendor
653 # This action is called on an change of the customer/vendor picker.
654 sub action_customer_vendor_changed {
657 setup_order_from_cv($self->order);
660 my $cv_method = $self->cv;
662 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
663 $self->js->show('#cp_row');
665 $self->js->hide('#cp_row');
668 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
669 $self->js->show('#shipto_row');
671 $self->js->hide('#shipto_row');
674 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
677 ->replaceWith('#order_cp_id', $self->build_contact_select)
678 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
679 ->replaceWith('#business_info_row', $self->build_business_info_row)
680 ->val( '#order_taxzone_id', $self->order->taxzone_id)
681 ->val( '#order_taxincluded', $self->order->taxincluded)
682 ->val( '#order_payment_id', $self->order->payment_id)
683 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
684 ->val( '#order_intnotes', $self->order->intnotes)
685 ->val( '#language_id', $self->order->$cv_method->language_id)
686 ->focus( '#order_' . $self->cv . '_id');
688 $self->js_redisplay_amounts_and_taxes;
689 $self->js_redisplay_cvpartnumbers;
693 # open the dialog for customer/vendor details
694 sub action_show_customer_vendor_details_dialog {
697 my $is_customer = 'customer' eq $::form->{vc};
700 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
702 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
705 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
706 $details{discount_as_percent} = $cv->discount_as_percent;
707 $details{creditlimt} = $cv->creditlimit_as_number;
708 $details{business} = $cv->business->description if $cv->business;
709 $details{language} = $cv->language_obj->description if $cv->language_obj;
710 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
711 $details{payment_terms} = $cv->payment->description if $cv->payment;
712 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
714 foreach my $entry (@{ $cv->shipto }) {
715 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
717 foreach my $entry (@{ $cv->contacts }) {
718 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
721 $_[0]->render('common/show_vc_details', { layout => 0 },
722 is_customer => $is_customer,
727 # called if a unit in an existing item row is changed
728 sub action_unit_changed {
731 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
732 my $item = $self->order->items_sorted->[$idx];
734 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
735 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
740 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
741 $self->js_redisplay_line_values;
742 $self->js_redisplay_amounts_and_taxes;
746 # add an item row for a new item entered in the input row
747 sub action_add_item {
750 my $form_attr = $::form->{add_item};
752 return unless $form_attr->{parts_id};
754 my $item = new_item($self->order, $form_attr);
756 $self->order->add_items($item);
760 $self->get_item_cvpartnumber($item);
762 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
763 my $row_as_html = $self->p->render('order/tabs/_row',
767 ALL_PRICE_FACTORS => $self->all_price_factors,
768 SEARCH_CVPARTNUMBER => $self->search_cvpartnumber,
772 ->append('#row_table_id', $row_as_html);
774 if ( $item->part->is_assortment ) {
775 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
776 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
777 my $attr = { parts_id => $assortment_item->parts_id,
778 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
779 unit => $assortment_item->unit,
780 description => $assortment_item->part->description,
782 my $item = new_item($self->order, $attr);
784 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
785 $item->discount(1) unless $assortment_item->charge;
787 $self->order->add_items( $item );
789 $self->get_item_cvpartnumber($item);
790 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
791 my $row_as_html = $self->p->render('order/tabs/_row',
795 ALL_PRICE_FACTORS => $self->all_price_factors,
796 SEARCH_CVPARTNUMBER => $self->search_cvpartnumber,
799 ->append('#row_table_id', $row_as_html);
804 ->val('.add_item_input', '')
805 ->run('kivi.Order.init_row_handlers')
806 ->run('kivi.Order.row_table_scroll_down')
807 ->run('kivi.Order.renumber_positions')
808 ->focus('#add_item_parts_id_name');
810 $self->js_redisplay_amounts_and_taxes;
814 # open the dialog for entering multiple items at once
815 sub action_show_multi_items_dialog {
816 $_[0]->render('order/tabs/_multi_items_dialog', { layout => 0 },
817 all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
820 # update the filter results in the multi item dialog
821 sub action_multi_items_update_result {
824 $::form->{multi_items}->{filter}->{obsolete} = 0;
826 my $count = $_[0]->multi_items_models->count;
829 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
830 $_[0]->render($text, { layout => 0 });
831 } elsif ($count > $max_count) {
832 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
833 $_[0]->render($text, { layout => 0 });
835 my $multi_items = $_[0]->multi_items_models->get;
836 $_[0]->render('order/tabs/_multi_items_result', { layout => 0 },
837 multi_items => $multi_items);
841 # add item rows for multiple items at once
842 sub action_add_multi_items {
845 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_multi_items} };
846 return $self->js->render() unless scalar @form_attr;
849 foreach my $attr (@form_attr) {
850 my $item = new_item($self->order, $attr);
852 if ( $item->part->is_assortment ) {
853 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
854 my $attr = { parts_id => $assortment_item->parts_id,
855 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
856 unit => $assortment_item->unit,
857 description => $assortment_item->part->description,
859 my $item = new_item($self->order, $attr);
861 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
862 $item->discount(1) unless $assortment_item->charge;
867 $self->order->add_items(@items);
871 foreach my $item (@items) {
872 $self->get_item_cvpartnumber($item);
873 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
874 my $row_as_html = $self->p->render('order/tabs/_row',
878 ALL_PRICE_FACTORS => $self->all_price_factors,
879 SEARCH_CVPARTNUMBER => $self->search_cvpartnumber,
882 $self->js->append('#row_table_id', $row_as_html);
886 ->run('kivi.Order.close_multi_items_dialog')
887 ->run('kivi.Order.init_row_handlers')
888 ->run('kivi.Order.row_table_scroll_down')
889 ->run('kivi.Order.renumber_positions')
890 ->focus('#add_item_parts_id_name');
892 $self->js_redisplay_amounts_and_taxes;
896 # recalculate all linetotals, amounts and taxes and redisplay them
897 sub action_recalc_amounts_and_taxes {
902 $self->js_redisplay_line_values;
903 $self->js_redisplay_amounts_and_taxes;
907 # redisplay item rows if they are sorted by an attribute
908 sub action_reorder_items {
912 partnumber => sub { $_[0]->part->partnumber },
913 description => sub { $_[0]->description },
914 qty => sub { $_[0]->qty },
915 sellprice => sub { $_[0]->sellprice },
916 discount => sub { $_[0]->discount },
917 cvpartnumber => sub { $_[0]->{cvpartnumber} },
920 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
922 my $method = $sort_keys{$::form->{order_by}};
923 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
924 if ($::form->{sort_dir}) {
925 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
926 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
928 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
931 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
932 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
934 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
938 ->run('kivi.Order.redisplay_items', \@to_sort)
942 # show the popup to choose a price/discount source
943 sub action_price_popup {
946 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
947 my $item = $self->order->items_sorted->[$idx];
949 $self->render_price_dialog($item);
952 # get the longdescription for an item if the dialog to enter/change the
953 # longdescription was opened and the longdescription is empty
955 # If this item is new, get the longdescription from Part.
956 # Otherwise get it from OrderItem.
957 sub action_get_item_longdescription {
960 if ($::form->{item_id}) {
961 $longdescription = SL::DB::OrderItem->new(id => $::form->{item_id})->load->longdescription;
962 } elsif ($::form->{parts_id}) {
963 $longdescription = SL::DB::Part->new(id => $::form->{parts_id})->load->notes;
965 $_[0]->render(\ $longdescription, { type => 'text' });
968 # load the second row for one or more items
970 # This action gets the html code for all items second rows by rendering a template for
971 # the second row and sets the html code via client js.
972 sub action_load_second_rows {
975 $self->recalc() if $self->order->is_sales; # for margin calculation
977 foreach my $item_id (@{ $::form->{item_ids} }) {
978 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
979 my $item = $self->order->items_sorted->[$idx];
981 $self->js_load_second_row($item, $item_id, 0);
984 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
989 sub js_load_second_row {
990 my ($self, $item, $item_id, $do_parse) = @_;
993 # Parse values from form (they are formated while rendering (template)).
994 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
995 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
996 foreach my $var (@{ $item->cvars_by_config }) {
997 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
999 $item->parse_custom_variable_values;
1002 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1005 ->html('#second_row_' . $item_id, $row_as_html)
1006 ->data('#second_row_' . $item_id, 'loaded', 1);
1009 sub js_redisplay_line_values {
1012 my $is_sales = $self->order->is_sales;
1014 # sales orders with margins
1019 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1020 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1021 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1022 ]} @{ $self->order->items_sorted };
1026 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1027 ]} @{ $self->order->items_sorted };
1031 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1034 sub js_redisplay_amounts_and_taxes {
1037 if (scalar @{ $self->{taxes} }) {
1038 $self->js->show('#taxincluded_row_id');
1040 $self->js->hide('#taxincluded_row_id');
1043 if ($self->order->taxincluded) {
1044 $self->js->hide('#subtotal_row_id');
1046 $self->js->show('#subtotal_row_id');
1049 if ($self->order->is_sales) {
1050 my $is_neg = $self->order->marge_total < 0;
1052 ->html('#marge_total_id', $::form->format_amount(\%::myconfig, $self->order->marge_total, 2))
1053 ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
1054 ->action_if( $is_neg, 'addClass', '#marge_total_id', 'plus0')
1055 ->action_if( $is_neg, 'addClass', '#marge_percent_id', 'plus0')
1056 ->action_if( $is_neg, 'addClass', '#marge_percent_sign_id', 'plus0')
1057 ->action_if(!$is_neg, 'removeClass', '#marge_total_id', 'plus0')
1058 ->action_if(!$is_neg, 'removeClass', '#marge_percent_id', 'plus0')
1059 ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
1063 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
1064 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
1065 ->remove('.tax_row')
1066 ->insertBefore($self->build_tax_rows, '#amount_row_id');
1069 sub js_redisplay_cvpartnumbers {
1072 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1074 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1077 ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
1080 sub js_reset_order_and_item_ids_after_save {
1084 ->val('#id', $self->order->id)
1085 ->val('#converted_from_oe_id', '')
1086 ->val('#order_' . $self->nr_key(), $self->order->number);
1089 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1090 next if !$self->order->items_sorted->[$idx]->id;
1091 next if $form_item_id !~ m{^new};
1093 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1094 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1095 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1099 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1106 sub init_valid_types {
1107 [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
1113 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1114 die "Not a valid type for order";
1117 $self->type($::form->{type});
1123 my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer'
1124 : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
1125 : die "Not a valid type for order";
1130 sub init_search_cvpartnumber {
1133 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1134 my $search_cvpartnumber;
1135 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1136 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1138 return $search_cvpartnumber;
1149 # model used to filter/display the parts in the multi-items dialog
1150 sub init_multi_items_models {
1151 SL::Controller::Helper::GetModels->new(
1152 controller => $_[0],
1154 with_objects => [ qw(unit_obj) ],
1155 disable_plugin => 'paginated',
1156 source => $::form->{multi_items},
1162 partnumber => t8('Partnumber'),
1163 description => t8('Description')}
1167 sub init_all_price_factors {
1168 SL::DB::Manager::PriceFactor->get_all;
1174 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
1176 my $right = $right_for->{ $self->type };
1177 $right ||= 'DOES_NOT_EXIST';
1179 $::auth->assert($right);
1182 # build the selection box for contacts
1184 # Needed, if customer/vendor changed.
1185 sub build_contact_select {
1188 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1189 value_key => 'cp_id',
1190 title_key => 'full_name_dep',
1191 default => $self->order->cp_id,
1193 style => 'width: 300px',
1197 # build the selection box for shiptos
1199 # Needed, if customer/vendor changed.
1200 sub build_shipto_select {
1203 select_tag('order.shipto_id', [ $self->order->{$self->cv}->shipto ],
1204 value_key => 'shipto_id',
1205 title_key => 'displayable_id',
1206 default => $self->order->shipto_id,
1208 style => 'width: 300px',
1212 # render the info line for business
1214 # Needed, if customer/vendor changed.
1215 sub build_business_info_row
1217 $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
1220 # build the rows for displaying taxes
1222 # Called if amounts where recalculated and redisplayed.
1223 sub build_tax_rows {
1227 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
1228 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
1230 return $rows_as_html;
1234 sub render_price_dialog {
1235 my ($self, $record_item) = @_;
1237 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1241 'kivi.io.price_chooser_dialog',
1242 t8('Available Prices'),
1243 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1248 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1249 # $self->js->show('#dialog_flash_error');
1258 return if !$::form->{id};
1260 $self->order(SL::DB::Order->new(id => $::form->{id})->load);
1263 # load or create a new order object
1265 # And assign changes from the form to this object.
1266 # If the order is loaded from db, check if items are deleted in the form,
1267 # remove them form the object and collect them for removing from db on saving.
1268 # Then create/update items from form (via make_item) and add them.
1272 # add_items adds items to an order with no items for saving, but they cannot
1273 # be retrieved via items until the order is saved. Adding empty items to new
1274 # order here solves this problem.
1276 $order = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1277 $order ||= SL::DB::Order->new(orderitems => [],
1278 quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())));
1280 my $cv_id_method = $self->cv . '_id';
1281 if (!$::form->{id} && $::form->{$cv_id_method}) {
1282 $order->$cv_id_method($::form->{$cv_id_method});
1283 setup_order_from_cv($order);
1286 my $form_orderitems = delete $::form->{order}->{orderitems};
1287 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1289 $order->assign_attributes(%{$::form->{order}});
1291 if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) {
1292 my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
1293 $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
1296 # remove deleted items
1297 $self->item_ids_to_delete([]);
1298 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1299 my $item = $order->orderitems->[$idx];
1300 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1301 splice @{$order->orderitems}, $idx, 1;
1302 push @{$self->item_ids_to_delete}, $item->id;
1308 foreach my $form_attr (@{$form_orderitems}) {
1309 my $item = make_item($order, $form_attr);
1310 $item->position($pos);
1314 $order->add_items(grep {!$_->id} @items);
1319 # create or update items from form
1321 # Make item objects from form values. For items already existing read from db.
1322 # Create a new item else. And assign attributes.
1324 my ($record, $attr) = @_;
1327 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1329 my $is_new = !$item;
1331 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1332 # they cannot be retrieved via custom_variables until the order/orderitem is
1333 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1334 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1336 $item->assign_attributes(%$attr);
1337 $item->longdescription($item->part->notes) if $is_new && !defined $attr->{longdescription};
1338 $item->project_id($record->globalproject_id) if $is_new && !defined $attr->{project_id};
1339 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if $is_new && !defined $attr->{lastcost_as_number};
1346 # This is used to add one item
1348 my ($record, $attr) = @_;
1350 my $item = SL::DB::OrderItem->new;
1352 # Remove attributes where the user left or set the inputs empty.
1353 # So these attributes will be undefined and we can distinguish them
1354 # from zero later on.
1355 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1356 delete $attr->{$_} if $attr->{$_} eq '';
1359 $item->assign_attributes(%$attr);
1361 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1362 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1364 $item->unit($part->unit) if !$item->unit;
1367 if ( $part->is_assortment ) {
1368 # add assortment items with price 0, as the components carry the price
1369 $price_src = $price_source->price_from_source("");
1370 $price_src->price(0);
1371 } elsif (defined $item->sellprice) {
1372 $price_src = $price_source->price_from_source("");
1373 $price_src->price($item->sellprice);
1375 $price_src = $price_source->best_price
1376 ? $price_source->best_price
1377 : $price_source->price_from_source("");
1378 $price_src->price(0) if !$price_source->best_price;
1382 if (defined $item->discount) {
1383 $discount_src = $price_source->discount_from_source("");
1384 $discount_src->discount($item->discount);
1386 $discount_src = $price_source->best_discount
1387 ? $price_source->best_discount
1388 : $price_source->discount_from_source("");
1389 $discount_src->discount(0) if !$price_source->best_discount;
1393 $new_attr{part} = $part;
1394 $new_attr{description} = $part->description if ! $item->description;
1395 $new_attr{qty} = 1.0 if ! $item->qty;
1396 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1397 $new_attr{sellprice} = $price_src->price;
1398 $new_attr{discount} = $discount_src->discount;
1399 $new_attr{active_price_source} = $price_src;
1400 $new_attr{active_discount_source} = $discount_src;
1401 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1402 $new_attr{project_id} = $record->globalproject_id;
1403 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1405 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1406 # they cannot be retrieved via custom_variables until the order/orderitem is
1407 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1408 $new_attr{custom_variables} = [];
1410 $item->assign_attributes(%new_attr);
1415 sub setup_order_from_cv {
1418 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id));
1420 $order->intnotes($order->customervendor->notes);
1422 if ($order->is_sales) {
1423 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1424 $order->taxincluded(defined($order->customer->taxincluded_checked)
1425 ? $order->customer->taxincluded_checked
1426 : $::myconfig{taxincluded_checked});
1431 # recalculate prices and taxes
1433 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1437 # bb: todo: currency later
1438 $self->order->currency_id($::instance_conf->get_currency_id());
1440 my %pat = $self->order->calculate_prices_and_taxes();
1441 $self->{taxes} = [];
1442 foreach my $tax_chart_id (keys %{ $pat{taxes} }) {
1443 my $tax = SL::DB::Manager::Tax->find_by(chart_id => $tax_chart_id);
1445 my @amount_keys = grep { $pat{amounts}->{$_}->{tax_id} == $tax->id } keys %{ $pat{amounts} };
1446 push(@{ $self->{taxes} }, { amount => $pat{taxes}->{$tax_chart_id},
1447 netamount => $pat{amounts}->{$amount_keys[0]}->{amount},
1451 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items_sorted}, @{$pat{items}};
1454 # get data for saving, printing, ..., that is not changed in the form
1456 # Only cvars for now.
1457 sub get_unalterable_data {
1460 foreach my $item (@{ $self->order->items }) {
1461 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1462 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1463 foreach my $var (@{ $item->cvars_by_config }) {
1464 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1466 $item->parse_custom_variable_values;
1472 # And remove related files in the spool directory
1477 my $db = $self->order->db;
1479 $db->with_transaction(
1481 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1482 $self->order->delete;
1483 my $spool = $::lx_office_conf{paths}->{spool};
1484 unlink map { "$spool/$_" } @spoolfiles if $spool;
1487 }) || push(@{$errors}, $db->error);
1494 # And delete items that are deleted in the form.
1499 my $db = $self->order->db;
1501 $db->with_transaction(sub {
1502 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1503 $self->order->save(cascade => 1);
1506 if ($::form->{converted_from_oe_id}) {
1507 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1508 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1509 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1510 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
1511 $src->link_to_record($self->order);
1513 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1515 foreach (@{ $self->order->items_sorted }) {
1516 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1518 SL::DB::RecordLink->new(from_table => 'orderitems',
1519 from_id => $from_id,
1520 to_table => 'orderitems',
1528 }) || push(@{$errors}, $db->error);
1533 sub workflow_sales_or_purchase_order {
1537 my $errors = $self->save();
1539 if (scalar @{ $errors }) {
1540 $self->js->flash('error', $_) foreach @{ $errors };
1541 return $self->js->render();
1544 my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type()
1545 : $::form->{type} eq request_quotation_type() ? purchase_order_type()
1546 : $::form->{type} eq purchase_order_type() ? sales_order_type()
1547 : $::form->{type} eq sales_order_type() ? purchase_order_type()
1550 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1551 $self->{converted_from_oe_id} = delete $::form->{id};
1553 # set item ids to new fake id, to identify them as new items
1554 foreach my $item (@{$self->order->items_sorted}) {
1555 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1559 $::form->{type} = $destination_type;
1560 $self->type($self->init_type);
1561 $self->cv ($self->init_cv);
1565 $self->get_unalterable_data();
1566 $self->pre_render();
1568 # trigger rendering values for second row/longdescription as hidden,
1569 # because they are loaded only on demand. So we need to keep the values
1571 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1572 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
1576 title => $self->get_title_for('edit'),
1577 %{$self->{template_args}}
1585 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1586 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1587 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1590 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1593 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1595 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1596 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1597 $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
1598 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1599 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1601 my $print_form = Form->new('');
1602 $print_form->{type} = $self->type;
1603 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1604 $print_form->{languages} = SL::DB::Manager::Language->get_all_sorted;
1605 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1606 form => $print_form,
1607 options => {dialog_name_prefix => 'print_options.',
1611 no_opendocument => 0,
1615 foreach my $item (@{$self->order->orderitems}) {
1616 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1617 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1618 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1621 if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
1622 # calculate shipped qtys here to prevent calling calculate for every item via the items method
1623 SL::Helper::ShippedQty->new->calculate($self->order)->write_to_objects;
1626 if ($self->order->number && $::instance_conf->get_webdav) {
1627 my $webdav = SL::Webdav->new(
1628 type => $self->type,
1629 number => $self->order->number,
1631 my @all_objects = $webdav->get_all_objects;
1632 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1634 link => File::Spec->catfile($_->full_filedescriptor),
1638 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1640 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery edit_periodic_invoices_config calculate_qty);
1641 $self->setup_edit_action_bar;
1644 sub setup_edit_action_bar {
1645 my ($self, %params) = @_;
1647 my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
1648 || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
1649 || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
1651 for my $bar ($::request->layout->get('actionbar')) {
1656 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1657 $::instance_conf->get_order_warn_no_deliverydate,
1659 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1663 call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1664 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1665 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1667 ], # end of combobox "Save"
1674 t8('Save and Sales Order'),
1675 submit => [ '#order_form', { action => "Order/sales_order" } ],
1676 only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
1677 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1680 t8('Save and Purchase Order'),
1681 submit => [ '#order_form', { action => "Order/purchase_order" } ],
1682 only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
1683 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1686 t8('Save and Delivery Order'),
1687 call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1688 $::instance_conf->get_order_warn_no_deliverydate,
1690 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1691 only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type()))
1694 t8('Save and Invoice'),
1695 call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1696 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1698 ], # end of combobox "Workflow"
1705 t8('Save and print'),
1706 call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts ],
1709 t8('Save and E-mail'),
1710 call => [ 'kivi.Order.email', $::instance_conf->get_order_warn_duplicate_parts ],
1713 t8('Download attachments of all parts'),
1714 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1715 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1716 only_if => $::instance_conf->get_doc_storage,
1718 ], # end of combobox "Export"
1722 call => [ 'kivi.Order.delete_order' ],
1723 confirm => $::locale->text('Do you really want to delete this object?'),
1724 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1725 only_if => $deletion_allowed,
1732 my ($order, $pdf_ref, $params) = @_;
1736 my $print_form = Form->new('');
1737 $print_form->{type} = $order->type;
1738 $print_form->{formname} = $params->{formname} || $order->type;
1739 $print_form->{format} = $params->{format} || 'pdf';
1740 $print_form->{media} = $params->{media} || 'file';
1741 $print_form->{groupitems} = $params->{groupitems};
1742 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1744 $order->language($params->{language});
1745 $order->flatten_to_form($print_form, format_amounts => 1);
1749 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
1750 $template_ext = 'odt';
1751 $template_type = 'OpenDocument';
1754 # search for the template
1755 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1756 name => $print_form->{formname},
1757 extension => $template_ext,
1758 email => $print_form->{media} eq 'email',
1759 language => $params->{language},
1760 printer_id => $print_form->{printer_id}, # todo
1763 if (!defined $template_file) {
1764 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);
1767 return @errors if scalar @errors;
1769 $print_form->throw_on_error(sub {
1771 $print_form->prepare_for_printing;
1773 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1774 format => $print_form->{format},
1775 template_type => $template_type,
1776 template => $template_file,
1777 variables => $print_form,
1778 variable_content_types => {
1779 longdescription => 'html',
1780 partnotes => 'html',
1785 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
1791 sub get_files_for_email_dialog {
1794 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
1796 return %files if !$::instance_conf->get_doc_storage;
1798 if ($self->order->id) {
1799 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
1800 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
1801 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
1805 uniq_by { $_->{id} }
1807 +{ id => $_->part->id,
1808 partnumber => $_->part->partnumber }
1809 } @{$self->order->items_sorted};
1811 foreach my $part (@parts) {
1812 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
1813 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
1816 foreach my $key (keys %files) {
1817 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
1823 sub make_periodic_invoices_config_from_yaml {
1824 my ($yaml_config) = @_;
1826 return if !$yaml_config;
1827 my $attr = SL::YAML::Load($yaml_config);
1828 return if 'HASH' ne ref $attr;
1829 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
1833 sub get_periodic_invoices_status {
1834 my ($self, $config) = @_;
1836 return if $self->type ne sales_order_type();
1837 return t8('not configured') if !$config;
1839 my $active = ('HASH' eq ref $config) ? $config->{active}
1840 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
1841 : die "Cannot get status of periodic invoices config";
1843 return $active ? t8('active') : t8('inactive');
1847 my ($self, $action) = @_;
1849 return '' if none { lc($action)} qw(add edit);
1852 # $::locale->text("Add Sales Order");
1853 # $::locale->text("Add Purchase Order");
1854 # $::locale->text("Add Quotation");
1855 # $::locale->text("Add Request for Quotation");
1856 # $::locale->text("Edit Sales Order");
1857 # $::locale->text("Edit Purchase Order");
1858 # $::locale->text("Edit Quotation");
1859 # $::locale->text("Edit Request for Quotation");
1861 $action = ucfirst(lc($action));
1862 return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order")
1863 : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order")
1864 : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation")
1865 : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
1869 sub get_item_cvpartnumber {
1870 my ($self, $item) = @_;
1872 if ($self->cv eq 'vendor') {
1873 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
1874 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
1875 } elsif ($self->cv eq 'customer') {
1876 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
1877 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
1881 sub sales_order_type {
1885 sub purchase_order_type {
1889 sub sales_quotation_type {
1893 sub request_quotation_type {
1894 'request_quotation';
1898 return $_[0]->type eq sales_order_type() ? 'ordnumber'
1899 : $_[0]->type eq purchase_order_type() ? 'ordnumber'
1900 : $_[0]->type eq sales_quotation_type() ? 'quonumber'
1901 : $_[0]->type eq request_quotation_type() ? 'quonumber'
1913 SL::Controller::Order - controller for orders
1917 This is a new form to enter orders, completely rewritten with the use
1918 of controller and java script techniques.
1920 The aim is to provide the user a better experience and a faster workflow. Also
1921 the code should be more readable, more reliable and better to maintain.
1929 One input row, so that input happens every time at the same place.
1933 Use of pickers where possible.
1937 Possibility to enter more than one item at once.
1941 Item list in a scrollable area, so that the workflow buttons stay at
1946 Reordering item rows with drag and drop is possible. Sorting item rows is
1947 possible (by partnumber, description, qty, sellprice and discount for now).
1951 No C<update> is necessary. All entries and calculations are managed
1952 with ajax-calls and the page only reloads on C<save>.
1956 User can see changes immediately, because of the use of java script
1967 =item * C<SL/Controller/Order.pm>
1971 =item * C<template/webpages/order/form.html>
1975 =item * C<template/webpages/order/tabs/basic_data.html>
1977 Main tab for basic_data.
1979 This is the only tab here for now. "linked records" and "webdav" tabs are
1980 reused from generic code.
1984 =item * C<template/webpages/order/tabs/_business_info_row.html>
1986 For displaying information on business type
1988 =item * C<template/webpages/order/tabs/_item_input.html>
1990 The input line for items
1992 =item * C<template/webpages/order/tabs/_row.html>
1994 One row for already entered items
1996 =item * C<template/webpages/order/tabs/_tax_row.html>
1998 Displaying tax information
2000 =item * C<template/webpages/order/tabs/_multi_items_dialog.html>
2002 Dialog for entering more than one item at once
2004 =item * C<template/webpages/order/tabs/_multi_items_result.html>
2006 Results for the filter in the multi items dialog
2008 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
2010 Dialog for selecting price and discount sources
2014 =item * C<js/kivi.Order.js>
2016 java script functions
2028 =item * credit limit
2030 =item * more workflows (quotation, rfq)
2032 =item * price sources: little symbols showing better price / better discount
2034 =item * select units in input row?
2036 =item * custom shipto address
2038 =item * check for direct delivery (workflow sales order -> purchase order)
2040 =item * language / part translations
2042 =item * access rights
2044 =item * display weights
2050 =item * optional client/user behaviour
2052 (transactions has to be set - department has to be set -
2053 force project if enabled in client config - transport cost reminder)
2057 =head1 KNOWN BUGS AND CAVEATS
2063 Customer discount is not displayed as a valid discount in price source popup
2064 (this might be a bug in price sources)
2066 (I cannot reproduce this (Bernd))
2070 No indication that <shift>-up/down expands/collapses second row.
2074 Inline creation of parts is not currently supported
2078 Table header is not sticky in the scrolling area.
2082 Sorting does not include C<position>, neither does reordering.
2084 This behavior was implemented intentionally. But we can discuss, which behavior
2085 should be implemented.
2089 C<show_multi_items_dialog> does not use the currently inserted string for
2094 The language selected in print or email dialog is not saved when the order is saved.
2098 =head1 To discuss / Nice to have
2104 How to expand/collapse second row. Now it can be done clicking the icon or
2109 Possibility to change longdescription in input row?
2113 Possibility to select PriceSources in input row?
2117 This controller uses a (changed) copy of the template for the PriceSource
2118 dialog. Maybe there could be used one code source.
2122 Rounding-differences between this controller (PriceTaxCalculator) and the old
2123 form. This is not only a problem here, but also in all parts using the PTC.
2124 There exists a ticket and a patch. This patch should be testet.
2128 An indicator, if the actual inputs are saved (like in an
2129 editor or on text processing application).
2133 A warning when leaving the page without saveing unchanged inputs.
2140 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>