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 div_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;
26 use SL::Helper::CreatePDF qw(:all);
27 use SL::Helper::PrintOptions;
28 use SL::Helper::ShippedQty;
29 use SL::Helper::UserPreferences::PositionsScrollbar;
30 use SL::Helper::UserPreferences::UpdatePositions;
32 use SL::Controller::Helper::GetModels;
34 use List::Util qw(first sum0);
35 use List::UtilsBy qw(sort_by uniq_by);
36 use List::MoreUtils qw(any none pairwise first_index);
37 use English qw(-no_match_vars);
42 use Rose::Object::MakeMethods::Generic
44 scalar => [ qw(item_ids_to_delete is_custom_shipto_to_delete) ],
45 'scalar --get_set_init' => [ qw(order valid_types type cv p multi_items_models all_price_factors search_cvpartnumber show_update_button) ],
50 __PACKAGE__->run_before('check_auth');
52 __PACKAGE__->run_before('recalc',
53 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
56 __PACKAGE__->run_before('get_unalterable_data',
57 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
68 $self->order->transdate(DateTime->now_local());
69 my $extra_days = $self->{type} eq 'sales_quotation' ? $::instance_conf->get_reqdate_interval :
70 $self->{type} eq 'sales_order' ? $::instance_conf->get_delivery_date_interval : 1;
71 $self->order->reqdate(DateTime->today_local->next_workday(extra_days => $extra_days)) if !$self->order->reqdate;
77 title => $self->get_title_for('add'),
78 %{$self->{template_args}}
82 # edit an existing order
90 # this is to edit an order from an unsaved order object
92 # set item ids to new fake id, to identify them as new items
93 foreach my $item (@{$self->order->items_sorted}) {
94 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
96 # trigger rendering values for second row/longdescription as hidden,
97 # because they are loaded only on demand. So we need to keep the values
99 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
100 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
107 title => $self->get_title_for('edit'),
108 %{$self->{template_args}}
112 # edit a collective order (consisting of one or more existing orders)
113 sub action_edit_collective {
117 my @multi_ids = map {
118 $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1}
119 } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form;
121 # fall back to add if no ids are given
122 if (scalar @multi_ids == 0) {
127 # fall back to save as new if only one id is given
128 if (scalar @multi_ids == 1) {
129 $self->order(SL::DB::Order->new(id => $multi_ids[0])->load);
130 $self->action_save_as_new();
134 # make new order from given orders
135 my @multi_orders = map { SL::DB::Order->new(id => $_)->load } @multi_ids;
136 $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders;
137 $self->order(SL::DB::Order->new_from_multi(\@multi_orders, sort_sources_by => 'transdate'));
139 $self->action_edit();
146 my $errors = $self->delete();
148 if (scalar @{ $errors }) {
149 $self->js->flash('error', $_) foreach @{ $errors };
150 return $self->js->render();
153 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been deleted')
154 : $self->type eq purchase_order_type() ? $::locale->text('The order has been deleted')
155 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been deleted')
156 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been deleted')
158 flash_later('info', $text);
160 my @redirect_params = (
165 $self->redirect_to(@redirect_params);
172 my $errors = $self->save();
174 if (scalar @{ $errors }) {
175 $self->js->flash('error', $_) foreach @{ $errors };
176 return $self->js->render();
179 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
180 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
181 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
182 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
184 flash_later('info', $text);
186 my @redirect_params = (
189 id => $self->order->id,
192 $self->redirect_to(@redirect_params);
195 # save the order as new document an open it for edit
196 sub action_save_as_new {
199 my $order = $self->order;
202 $self->js->flash('error', t8('This object has not been saved yet.'));
203 return $self->js->render();
206 # load order from db to check if values changed
207 my $saved_order = SL::DB::Order->new(id => $order->id)->load;
210 # Lets assign a new number if the user hasn't changed the previous one.
211 # If it has been changed manually then use it as-is.
212 $new_attrs{number} = (trim($order->number) eq $saved_order->number)
214 : trim($order->number);
216 # Clear transdate unless changed
217 $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
218 ? DateTime->today_local
221 # Set new reqdate unless changed
222 if ($order->reqdate == $saved_order->reqdate) {
223 my $extra_days = $self->{type} eq 'sales_quotation' ? $::instance_conf->get_reqdate_interval :
224 $self->{type} eq 'sales_order' ? $::instance_conf->get_delivery_date_interval : 1;
225 $new_attrs{reqdate} = DateTime->today_local->next_workday(extra_days => $extra_days);
227 $new_attrs{reqdate} = $order->reqdate;
231 $new_attrs{employee} = SL::DB::Manager::Employee->current;
233 # Create new record from current one
234 $self->order(SL::DB::Order->new_from($order, destination_type => $order->type, attributes => \%new_attrs));
236 # no linked records on save as new
237 delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
240 $self->action_save();
245 # This is called if "print" is pressed in the print dialog.
246 # If PDF creation was requested and succeeded, the pdf is offered for download
247 # via send_file (which uses ajax in this case).
251 my $errors = $self->save();
253 if (scalar @{ $errors }) {
254 $self->js->flash('error', $_) foreach @{ $errors };
255 return $self->js->render();
258 $self->js_reset_order_and_item_ids_after_save;
260 my $format = $::form->{print_options}->{format};
261 my $media = $::form->{print_options}->{media};
262 my $formname = $::form->{print_options}->{formname};
263 my $copies = $::form->{print_options}->{copies};
264 my $groupitems = $::form->{print_options}->{groupitems};
266 # only pdf and opendocument by now
267 if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf)) {
268 return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
271 # only screen or printer by now
272 if (none { $media eq $_ } qw(screen printer)) {
273 return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
277 $language = SL::DB::Language->new(id => $::form->{print_options}->{language_id})->load if $::form->{print_options}->{language_id};
279 # create a form for generate_attachment_filename
280 my $form = Form->new;
281 $form->{$self->nr_key()} = $self->order->number;
282 $form->{type} = $self->type;
283 $form->{format} = $format;
284 $form->{formname} = $formname;
285 $form->{language} = '_' . $language->template_code if $language;
286 my $pdf_filename = $form->generate_attachment_filename();
289 my @errors = generate_pdf($self->order, \$pdf, { format => $format,
290 formname => $formname,
291 language => $language,
292 groupitems => $groupitems });
293 if (scalar @errors) {
294 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render;
297 if ($media eq 'screen') {
299 $self->js->flash('info', t8('The PDF has been created'));
302 type => SL::MIME->mime_type_from_ext($pdf_filename),
303 name => $pdf_filename,
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 # open the email dialog
353 sub action_save_and_show_email_dialog {
356 my $errors = $self->save();
358 if (scalar @{ $errors }) {
359 $self->js->flash('error', $_) foreach @{ $errors };
360 return $self->js->render();
363 my $cv_method = $self->cv;
365 if (!$self->order->$cv_method) {
366 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'))
371 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
372 $email_form->{to} ||= $self->order->$cv_method->email;
373 $email_form->{cc} = $self->order->$cv_method->cc;
374 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
375 # Todo: get addresses from shipto, if any
377 my $form = Form->new;
378 $form->{$self->nr_key()} = $self->order->number;
379 $form->{formname} = $self->type;
380 $form->{type} = $self->type;
381 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
382 $form->{language_id} = $self->order->language->id if $self->order->language;
383 $form->{cusordnumber} = $self->order->cusordnumber;
384 $form->{format} = 'pdf';
386 $email_form->{subject} = $form->generate_email_subject();
387 $email_form->{attachment_filename} = $form->generate_attachment_filename();
388 $email_form->{message} = $form->generate_email_body();
389 $email_form->{js_send_function} = 'kivi.Order.send_email()';
391 my %files = $self->get_files_for_email_dialog();
392 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
393 email_form => $email_form,
394 show_bcc => $::auth->assert('email_bcc', 'may fail'),
396 is_customer => $self->cv eq 'customer',
400 ->run('kivi.Order.show_email_dialog', $dialog_html)
407 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
408 sub action_send_email {
411 my $errors = $self->save();
413 if (scalar @{ $errors }) {
414 $self->js->run('kivi.Order.close_email_dialog');
415 $self->js->flash('error', $_) foreach @{ $errors };
416 return $self->js->render();
419 $self->js_reset_order_and_item_ids_after_save;
421 my $email_form = delete $::form->{email_form};
422 my %field_names = (to => 'email');
424 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
426 # for Form::cleanup which may be called in Form::send_email
427 $::form->{cwd} = getcwd();
428 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
430 $::form->{$_} = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
431 $::form->{media} = 'email';
433 if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) {
435 $language = SL::DB::Language->new(id => $::form->{print_options}->{language_id})->load if $::form->{print_options}->{language_id};
438 my @errors = generate_pdf($self->order, \$pdf, {media => $::form->{media},
439 format => $::form->{print_options}->{format},
440 formname => $::form->{print_options}->{formname},
441 language => $language,
442 groupitems => $::form->{print_options}->{groupitems}});
443 if (scalar @errors) {
444 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
447 my $sfile = SL::SessionFile::Random->new(mode => "w");
448 $sfile->fh->print($pdf);
451 $::form->{tmpfile} = $sfile->file_name;
452 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
455 $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a linked record to the mail
456 $::form->send_email(\%::myconfig, 'pdf');
459 my $intnotes = $self->order->intnotes;
460 $intnotes .= "\n\n" if $self->order->intnotes;
461 $intnotes .= t8('[email]') . "\n";
462 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
463 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
464 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
465 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
466 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
467 $intnotes .= t8('Message') . ": " . $::form->{message};
469 $self->order->update_attributes(intnotes => $intnotes);
472 ->val('#order_intnotes', $intnotes)
473 ->run('kivi.Order.close_email_dialog')
474 ->flash('info', t8('The email has been sent.'))
478 # open the periodic invoices config dialog
480 # If there are values in the form (i.e. dialog was opened before),
481 # then use this values. Create new ones, else.
482 sub action_show_periodic_invoices_config_dialog {
485 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
486 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
487 $config ||= SL::DB::PeriodicInvoicesConfig->new(periodicity => 'm',
488 order_value_periodicity => 'p', # = same as periodicity
489 start_date_as_date => $::form->{transdate_as_date} || $::form->current_date,
490 extend_automatically_by => 12,
492 email_subject => GenericTranslations->get(
493 language_id => $::form->{language_id},
494 translation_type =>"preset_text_periodic_invoices_email_subject"),
495 email_body => GenericTranslations->get(
496 language_id => $::form->{language_id},
497 translation_type =>"preset_text_periodic_invoices_email_body"),
499 $config->periodicity('m') if none { $_ eq $config->periodicity } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
500 $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
502 $::form->get_lists(printers => "ALL_PRINTERS",
503 charts => { key => 'ALL_CHARTS',
504 transdate => 'current_date' });
506 $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
508 if ($::form->{customer_id}) {
509 $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
510 $::form->{email_recipient_invoice_address} = SL::DB::Manager::Customer->find_by(id => $::form->{customer_id})->invoice_mail;
513 $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
515 popup_js_close_function => 'kivi.Order.close_periodic_invoices_config_dialog()',
516 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
521 # assign the values of the periodic invoices config dialog
522 # as yaml in the hidden tag and set the status.
523 sub action_assign_periodic_invoices_config {
526 $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
528 my $config = { active => $::form->{active} ? 1 : 0,
529 terminated => $::form->{terminated} ? 1 : 0,
530 direct_debit => $::form->{direct_debit} ? 1 : 0,
531 periodicity => (any { $_ eq $::form->{periodicity} } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES) ? $::form->{periodicity} : 'm',
532 order_value_periodicity => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
533 start_date_as_date => $::form->{start_date_as_date},
534 end_date_as_date => $::form->{end_date_as_date},
535 first_billing_date_as_date => $::form->{first_billing_date_as_date},
536 print => $::form->{print} ? 1 : 0,
537 printer_id => $::form->{print} ? $::form->{printer_id} * 1 : undef,
538 copies => $::form->{copies} * 1 ? $::form->{copies} : 1,
539 extend_automatically_by => $::form->{extend_automatically_by} * 1 || undef,
540 ar_chart_id => $::form->{ar_chart_id} * 1,
541 send_email => $::form->{send_email} ? 1 : 0,
542 email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
543 email_recipient_address => $::form->{email_recipient_address},
544 email_sender => $::form->{email_sender},
545 email_subject => $::form->{email_subject},
546 email_body => $::form->{email_body},
549 my $periodic_invoices_config = SL::YAML::Dump($config);
551 my $status = $self->get_periodic_invoices_status($config);
554 ->remove('#order_periodic_invoices_config')
555 ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
556 ->run('kivi.Order.close_periodic_invoices_config_dialog')
557 ->html('#periodic_invoices_status', $status)
558 ->flash('info', t8('The periodic invoices config has been assigned.'))
562 sub action_get_has_active_periodic_invoices {
565 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
566 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
568 my $has_active_periodic_invoices =
569 $self->type eq sales_order_type()
572 && (!$config->end_date || ($config->end_date > DateTime->today_local))
573 && $config->get_previous_billed_period_start_date;
575 $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
578 # save the order and redirect to the frontend subroutine for a new
580 sub action_save_and_delivery_order {
583 my $errors = $self->save();
585 if (scalar @{ $errors }) {
586 $self->js->flash('error', $_) foreach @{ $errors };
587 return $self->js->render();
590 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
591 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
592 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
593 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
595 flash_later('info', $text);
597 my @redirect_params = (
598 controller => 'oe.pl',
599 action => 'oe_delivery_order_from_order',
600 id => $self->order->id,
603 $self->redirect_to(@redirect_params);
606 # save the order and redirect to the frontend subroutine for a new
608 sub action_save_and_invoice {
611 my $errors = $self->save();
613 if (scalar @{ $errors }) {
614 $self->js->flash('error', $_) foreach @{ $errors };
615 return $self->js->render();
618 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
619 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
620 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
621 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
623 flash_later('info', $text);
625 my @redirect_params = (
626 controller => 'oe.pl',
627 action => 'oe_invoice_from_order',
628 id => $self->order->id,
631 $self->redirect_to(@redirect_params);
634 # workflow from sales quotation to sales order
635 sub action_sales_order {
636 $_[0]->workflow_sales_or_purchase_order();
639 # workflow from rfq to purchase order
640 sub action_purchase_order {
641 $_[0]->workflow_sales_or_purchase_order();
644 # workflow from purchase order to ap transaction
645 sub action_save_and_ap_transaction {
648 my $errors = $self->save();
650 if (scalar @{ $errors }) {
651 $self->js->flash('error', $_) foreach @{ $errors };
652 return $self->js->render();
655 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
656 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
657 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
658 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
660 flash_later('info', $text);
662 my @redirect_params = (
663 controller => 'ap.pl',
664 action => 'add_from_purchase_order',
665 id => $self->order->id,
668 $self->redirect_to(@redirect_params);
671 # set form elements in respect to a changed customer or vendor
673 # This action is called on an change of the customer/vendor picker.
674 sub action_customer_vendor_changed {
677 setup_order_from_cv($self->order);
680 my $cv_method = $self->cv;
682 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
683 $self->js->show('#cp_row');
685 $self->js->hide('#cp_row');
688 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
689 $self->js->show('#shipto_selection');
691 $self->js->hide('#shipto_selection');
694 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
697 ->replaceWith('#order_cp_id', $self->build_contact_select)
698 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
699 ->replaceWith('#shipto_inputs ', $self->build_shipto_inputs)
700 ->replaceWith('#business_info_row', $self->build_business_info_row)
701 ->val( '#order_taxzone_id', $self->order->taxzone_id)
702 ->val( '#order_taxincluded', $self->order->taxincluded)
703 ->val( '#order_currency_id', $self->order->currency_id)
704 ->val( '#order_payment_id', $self->order->payment_id)
705 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
706 ->val( '#order_intnotes', $self->order->intnotes)
707 ->val( '#language_id', $self->order->$cv_method->language_id)
708 ->focus( '#order_' . $self->cv . '_id')
709 ->run('kivi.Order.update_exchangerate');
711 $self->js_redisplay_amounts_and_taxes;
712 $self->js_redisplay_cvpartnumbers;
716 # open the dialog for customer/vendor details
717 sub action_show_customer_vendor_details_dialog {
720 my $is_customer = 'customer' eq $::form->{vc};
723 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
725 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
728 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
729 $details{discount_as_percent} = $cv->discount_as_percent;
730 $details{creditlimt} = $cv->creditlimit_as_number;
731 $details{business} = $cv->business->description if $cv->business;
732 $details{language} = $cv->language_obj->description if $cv->language_obj;
733 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
734 $details{payment_terms} = $cv->payment->description if $cv->payment;
735 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
737 foreach my $entry (@{ $cv->shipto }) {
738 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
740 foreach my $entry (@{ $cv->contacts }) {
741 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
744 $_[0]->render('common/show_vc_details', { layout => 0 },
745 is_customer => $is_customer,
750 # called if a unit in an existing item row is changed
751 sub action_unit_changed {
754 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
755 my $item = $self->order->items_sorted->[$idx];
757 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
758 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
763 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
764 $self->js_redisplay_line_values;
765 $self->js_redisplay_amounts_and_taxes;
769 # add an item row for a new item entered in the input row
770 sub action_add_item {
773 my $form_attr = $::form->{add_item};
775 return unless $form_attr->{parts_id};
777 my $item = new_item($self->order, $form_attr);
779 $self->order->add_items($item);
783 $self->get_item_cvpartnumber($item);
785 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
786 my $row_as_html = $self->p->render('order/tabs/_row',
792 if ($::form->{insert_before_item_id}) {
794 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
797 ->append('#row_table_id', $row_as_html);
800 if ( $item->part->is_assortment ) {
801 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
802 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
803 my $attr = { parts_id => $assortment_item->parts_id,
804 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
805 unit => $assortment_item->unit,
806 description => $assortment_item->part->description,
808 my $item = new_item($self->order, $attr);
810 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
811 $item->discount(1) unless $assortment_item->charge;
813 $self->order->add_items( $item );
815 $self->get_item_cvpartnumber($item);
816 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
817 my $row_as_html = $self->p->render('order/tabs/_row',
822 if ($::form->{insert_before_item_id}) {
824 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
827 ->append('#row_table_id', $row_as_html);
833 ->val('.add_item_input', '')
834 ->run('kivi.Order.init_row_handlers')
835 ->run('kivi.Order.renumber_positions')
836 ->focus('#add_item_parts_id_name');
838 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
840 $self->js_redisplay_amounts_and_taxes;
844 # open the dialog for entering multiple items at once
845 sub action_show_multi_items_dialog {
846 $_[0]->render('order/tabs/_multi_items_dialog', { layout => 0 },
847 all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
850 # update the filter results in the multi item dialog
851 sub action_multi_items_update_result {
854 $::form->{multi_items}->{filter}->{obsolete} = 0;
856 my $count = $_[0]->multi_items_models->count;
859 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
860 $_[0]->render($text, { layout => 0 });
861 } elsif ($count > $max_count) {
862 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
863 $_[0]->render($text, { layout => 0 });
865 my $multi_items = $_[0]->multi_items_models->get;
866 $_[0]->render('order/tabs/_multi_items_result', { layout => 0 },
867 multi_items => $multi_items);
871 # add item rows for multiple items at once
872 sub action_add_multi_items {
875 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_multi_items} };
876 return $self->js->render() unless scalar @form_attr;
879 foreach my $attr (@form_attr) {
880 my $item = new_item($self->order, $attr);
882 if ( $item->part->is_assortment ) {
883 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
884 my $attr = { parts_id => $assortment_item->parts_id,
885 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
886 unit => $assortment_item->unit,
887 description => $assortment_item->part->description,
889 my $item = new_item($self->order, $attr);
891 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
892 $item->discount(1) unless $assortment_item->charge;
897 $self->order->add_items(@items);
901 foreach my $item (@items) {
902 $self->get_item_cvpartnumber($item);
903 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
904 my $row_as_html = $self->p->render('order/tabs/_row',
910 if ($::form->{insert_before_item_id}) {
912 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
915 ->append('#row_table_id', $row_as_html);
920 ->run('kivi.Order.close_multi_items_dialog')
921 ->run('kivi.Order.init_row_handlers')
922 ->run('kivi.Order.renumber_positions')
923 ->focus('#add_item_parts_id_name');
925 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
927 $self->js_redisplay_amounts_and_taxes;
931 # recalculate all linetotals, amounts and taxes and redisplay them
932 sub action_recalc_amounts_and_taxes {
937 $self->js_redisplay_line_values;
938 $self->js_redisplay_amounts_and_taxes;
942 sub action_update_exchangerate {
946 is_standard => $self->order->currency_id == $::instance_conf->get_currency_id,
947 currency_name => $self->order->currency->name,
948 exchangerate => $self->order->daily_exchangerate_as_null_number,
951 $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
954 # redisplay item rows if they are sorted by an attribute
955 sub action_reorder_items {
959 partnumber => sub { $_[0]->part->partnumber },
960 description => sub { $_[0]->description },
961 qty => sub { $_[0]->qty },
962 sellprice => sub { $_[0]->sellprice },
963 discount => sub { $_[0]->discount },
964 cvpartnumber => sub { $_[0]->{cvpartnumber} },
967 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
969 my $method = $sort_keys{$::form->{order_by}};
970 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
971 if ($::form->{sort_dir}) {
972 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
973 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
975 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
978 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
979 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
981 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
985 ->run('kivi.Order.redisplay_items', \@to_sort)
989 # show the popup to choose a price/discount source
990 sub action_price_popup {
993 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
994 my $item = $self->order->items_sorted->[$idx];
996 $self->render_price_dialog($item);
999 # get the longdescription for an item if the dialog to enter/change the
1000 # longdescription was opened and the longdescription is empty
1002 # If this item is new, get the longdescription from Part.
1003 # Otherwise get it from OrderItem.
1004 sub action_get_item_longdescription {
1005 my $longdescription;
1007 if ($::form->{item_id}) {
1008 $longdescription = SL::DB::OrderItem->new(id => $::form->{item_id})->load->longdescription;
1009 } elsif ($::form->{parts_id}) {
1010 $longdescription = SL::DB::Part->new(id => $::form->{parts_id})->load->notes;
1012 $_[0]->render(\ $longdescription, { type => 'text' });
1015 # load the second row for one or more items
1017 # This action gets the html code for all items second rows by rendering a template for
1018 # the second row and sets the html code via client js.
1019 sub action_load_second_rows {
1022 $self->recalc() if $self->order->is_sales; # for margin calculation
1024 foreach my $item_id (@{ $::form->{item_ids} }) {
1025 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1026 my $item = $self->order->items_sorted->[$idx];
1028 $self->js_load_second_row($item, $item_id, 0);
1031 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
1033 $self->js->render();
1036 # update description, notes and sellprice from master data
1037 sub action_update_row_from_master_data {
1040 foreach my $item_id (@{ $::form->{item_ids} }) {
1041 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1042 my $item = $self->order->items_sorted->[$idx];
1044 $item->description($item->part->description);
1045 $item->longdescription($item->part->notes);
1047 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1050 if ($item->part->is_assortment) {
1051 # add assortment items with price 0, as the components carry the price
1052 $price_src = $price_source->price_from_source("");
1053 $price_src->price(0);
1055 $price_src = $price_source->best_price
1056 ? $price_source->best_price
1057 : $price_source->price_from_source("");
1058 $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate;
1059 $price_src->price(0) if !$price_source->best_price;
1063 $item->sellprice($price_src->price);
1064 $item->active_price_source($price_src);
1067 ->run('kivi.Order.update_sellprice', $item_id, $item->sellprice_as_number)
1068 ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
1069 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
1070 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
1072 if ($self->search_cvpartnumber) {
1073 $self->get_item_cvpartnumber($item);
1074 $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
1079 $self->js_redisplay_line_values;
1080 $self->js_redisplay_amounts_and_taxes;
1082 $self->js->render();
1085 sub js_load_second_row {
1086 my ($self, $item, $item_id, $do_parse) = @_;
1089 # Parse values from form (they are formated while rendering (template)).
1090 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1091 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1092 foreach my $var (@{ $item->cvars_by_config }) {
1093 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1095 $item->parse_custom_variable_values;
1098 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1101 ->html('#second_row_' . $item_id, $row_as_html)
1102 ->data('#second_row_' . $item_id, 'loaded', 1);
1105 sub js_redisplay_line_values {
1108 my $is_sales = $self->order->is_sales;
1110 # sales orders with margins
1115 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1116 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1117 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1118 ]} @{ $self->order->items_sorted };
1122 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1123 ]} @{ $self->order->items_sorted };
1127 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1130 sub js_redisplay_amounts_and_taxes {
1133 if (scalar @{ $self->{taxes} }) {
1134 $self->js->show('#taxincluded_row_id');
1136 $self->js->hide('#taxincluded_row_id');
1139 if ($self->order->taxincluded) {
1140 $self->js->hide('#subtotal_row_id');
1142 $self->js->show('#subtotal_row_id');
1145 if ($self->order->is_sales) {
1146 my $is_neg = $self->order->marge_total < 0;
1148 ->html('#marge_total_id', $::form->format_amount(\%::myconfig, $self->order->marge_total, 2))
1149 ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
1150 ->action_if( $is_neg, 'addClass', '#marge_total_id', 'plus0')
1151 ->action_if( $is_neg, 'addClass', '#marge_percent_id', 'plus0')
1152 ->action_if( $is_neg, 'addClass', '#marge_percent_sign_id', 'plus0')
1153 ->action_if(!$is_neg, 'removeClass', '#marge_total_id', 'plus0')
1154 ->action_if(!$is_neg, 'removeClass', '#marge_percent_id', 'plus0')
1155 ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
1159 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
1160 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
1161 ->remove('.tax_row')
1162 ->insertBefore($self->build_tax_rows, '#amount_row_id');
1165 sub js_redisplay_cvpartnumbers {
1168 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1170 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1173 ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
1176 sub js_reset_order_and_item_ids_after_save {
1180 ->val('#id', $self->order->id)
1181 ->val('#converted_from_oe_id', '')
1182 ->val('#order_' . $self->nr_key(), $self->order->number);
1185 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1186 next if !$self->order->items_sorted->[$idx]->id;
1187 next if $form_item_id !~ m{^new};
1189 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1190 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1191 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1195 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1202 sub init_valid_types {
1203 [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
1209 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1210 die "Not a valid type for order";
1213 $self->type($::form->{type});
1219 my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer'
1220 : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
1221 : die "Not a valid type for order";
1226 sub init_search_cvpartnumber {
1229 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1230 my $search_cvpartnumber;
1231 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1232 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1234 return $search_cvpartnumber;
1237 sub init_show_update_button {
1240 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1251 # model used to filter/display the parts in the multi-items dialog
1252 sub init_multi_items_models {
1253 SL::Controller::Helper::GetModels->new(
1254 controller => $_[0],
1256 with_objects => [ qw(unit_obj) ],
1257 disable_plugin => 'paginated',
1258 source => $::form->{multi_items},
1264 partnumber => t8('Partnumber'),
1265 description => t8('Description')}
1269 sub init_all_price_factors {
1270 SL::DB::Manager::PriceFactor->get_all;
1276 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
1278 my $right = $right_for->{ $self->type };
1279 $right ||= 'DOES_NOT_EXIST';
1281 $::auth->assert($right);
1284 # build the selection box for contacts
1286 # Needed, if customer/vendor changed.
1287 sub build_contact_select {
1290 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1291 value_key => 'cp_id',
1292 title_key => 'full_name_dep',
1293 default => $self->order->cp_id,
1295 style => 'width: 300px',
1299 # build the selection box for shiptos
1301 # Needed, if customer/vendor changed.
1302 sub build_shipto_select {
1305 select_tag('order.shipto_id',
1306 [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
1307 value_key => 'shipto_id',
1308 title_key => 'displayable_id',
1309 default => $self->order->shipto_id,
1311 style => 'width: 300px',
1315 # build the inputs for the cusom shipto dialog
1317 # Needed, if customer/vendor changed.
1318 sub build_shipto_inputs {
1321 my $content = $self->p->render('common/_ship_to_dialog',
1322 vc_obj => $self->order->customervendor,
1323 cs_obj => $self->order->custom_shipto,
1324 cvars => $self->order->custom_shipto->cvars_by_config,
1325 id_selector => '#order_shipto_id');
1327 div_tag($content, id => 'shipto_inputs');
1330 # render the info line for business
1332 # Needed, if customer/vendor changed.
1333 sub build_business_info_row
1335 $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
1338 # build the rows for displaying taxes
1340 # Called if amounts where recalculated and redisplayed.
1341 sub build_tax_rows {
1345 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
1346 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
1348 return $rows_as_html;
1352 sub render_price_dialog {
1353 my ($self, $record_item) = @_;
1355 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1359 'kivi.io.price_chooser_dialog',
1360 t8('Available Prices'),
1361 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1366 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1367 # $self->js->show('#dialog_flash_error');
1376 return if !$::form->{id};
1378 $self->order(SL::DB::Order->new(id => $::form->{id})->load);
1380 # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
1381 # You need a custom shipto object to call cvars_by_config to get the cvars.
1382 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
1384 return $self->order;
1387 # load or create a new order object
1389 # And assign changes from the form to this object.
1390 # If the order is loaded from db, check if items are deleted in the form,
1391 # remove them form the object and collect them for removing from db on saving.
1392 # Then create/update items from form (via make_item) and add them.
1396 # add_items adds items to an order with no items for saving, but they cannot
1397 # be retrieved via items until the order is saved. Adding empty items to new
1398 # order here solves this problem.
1400 $order = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1401 $order ||= SL::DB::Order->new(orderitems => [],
1402 quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())),
1403 currency_id => $::instance_conf->get_currency_id(),);
1405 my $cv_id_method = $self->cv . '_id';
1406 if (!$::form->{id} && $::form->{$cv_id_method}) {
1407 $order->$cv_id_method($::form->{$cv_id_method});
1408 setup_order_from_cv($order);
1411 my $form_orderitems = delete $::form->{order}->{orderitems};
1412 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1414 $order->assign_attributes(%{$::form->{order}});
1416 $self->setup_custom_shipto_from_form($order, $::form);
1418 if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) {
1419 my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
1420 $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
1423 # remove deleted items
1424 $self->item_ids_to_delete([]);
1425 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1426 my $item = $order->orderitems->[$idx];
1427 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1428 splice @{$order->orderitems}, $idx, 1;
1429 push @{$self->item_ids_to_delete}, $item->id;
1435 foreach my $form_attr (@{$form_orderitems}) {
1436 my $item = make_item($order, $form_attr);
1437 $item->position($pos);
1441 $order->add_items(grep {!$_->id} @items);
1446 # create or update items from form
1448 # Make item objects from form values. For items already existing read from db.
1449 # Create a new item else. And assign attributes.
1451 my ($record, $attr) = @_;
1454 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1456 my $is_new = !$item;
1458 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1459 # they cannot be retrieved via custom_variables until the order/orderitem is
1460 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1461 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1463 $item->assign_attributes(%$attr);
1464 $item->longdescription($item->part->notes) if $is_new && !defined $attr->{longdescription};
1465 $item->project_id($record->globalproject_id) if $is_new && !defined $attr->{project_id};
1466 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if $is_new && !defined $attr->{lastcost_as_number};
1473 # This is used to add one item
1475 my ($record, $attr) = @_;
1477 my $item = SL::DB::OrderItem->new;
1479 # Remove attributes where the user left or set the inputs empty.
1480 # So these attributes will be undefined and we can distinguish them
1481 # from zero later on.
1482 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1483 delete $attr->{$_} if $attr->{$_} eq '';
1486 $item->assign_attributes(%$attr);
1488 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1489 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1491 $item->unit($part->unit) if !$item->unit;
1494 if ( $part->is_assortment ) {
1495 # add assortment items with price 0, as the components carry the price
1496 $price_src = $price_source->price_from_source("");
1497 $price_src->price(0);
1498 } elsif (defined $item->sellprice) {
1499 $price_src = $price_source->price_from_source("");
1500 $price_src->price($item->sellprice);
1502 $price_src = $price_source->best_price
1503 ? $price_source->best_price
1504 : $price_source->price_from_source("");
1505 $price_src->price($::form->round_amount($price_src->price / $record->exchangerate, 5)) if $record->exchangerate;
1506 $price_src->price(0) if !$price_source->best_price;
1510 if (defined $item->discount) {
1511 $discount_src = $price_source->discount_from_source("");
1512 $discount_src->discount($item->discount);
1514 $discount_src = $price_source->best_discount
1515 ? $price_source->best_discount
1516 : $price_source->discount_from_source("");
1517 $discount_src->discount(0) if !$price_source->best_discount;
1521 $new_attr{part} = $part;
1522 $new_attr{description} = $part->description if ! $item->description;
1523 $new_attr{qty} = 1.0 if ! $item->qty;
1524 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1525 $new_attr{sellprice} = $price_src->price;
1526 $new_attr{discount} = $discount_src->discount;
1527 $new_attr{active_price_source} = $price_src;
1528 $new_attr{active_discount_source} = $discount_src;
1529 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1530 $new_attr{project_id} = $record->globalproject_id;
1531 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1533 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1534 # they cannot be retrieved via custom_variables until the order/orderitem is
1535 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1536 $new_attr{custom_variables} = [];
1538 $item->assign_attributes(%new_attr);
1543 sub setup_order_from_cv {
1546 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id));
1548 $order->intnotes($order->customervendor->notes);
1550 if ($order->is_sales) {
1551 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1552 $order->taxincluded(defined($order->customer->taxincluded_checked)
1553 ? $order->customer->taxincluded_checked
1554 : $::myconfig{taxincluded_checked});
1559 # setup custom shipto from form
1561 # The dialog returns form variables starting with 'shipto' and cvars starting
1562 # with 'shiptocvar_'.
1563 # Mark it to be deleted if a shipto from master data is selected
1564 # (i.e. order has a shipto).
1565 # Else, update or create a new custom shipto. If the fields are empty, it
1566 # will not be saved on save.
1567 sub setup_custom_shipto_from_form {
1568 my ($self, $order, $form) = @_;
1570 if ($order->shipto) {
1571 $self->is_custom_shipto_to_delete(1);
1573 my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1575 my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
1576 my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
1578 $custom_shipto->assign_attributes(%$shipto_attrs);
1579 $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
1583 # recalculate prices and taxes
1585 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1589 my %pat = $self->order->calculate_prices_and_taxes();
1591 $self->{taxes} = [];
1592 foreach my $tax_id (keys %{ $pat{taxes_by_tax_id} }) {
1593 my $netamount = sum0 map { $pat{amounts}->{$_}->{amount} } grep { $pat{amounts}->{$_}->{tax_id} == $tax_id } keys %{ $pat{amounts} };
1595 push(@{ $self->{taxes} }, { amount => $pat{taxes_by_tax_id}->{$tax_id},
1596 netamount => $netamount,
1597 tax => SL::DB::Tax->new(id => $tax_id)->load });
1599 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items_sorted}, @{$pat{items}};
1602 # get data for saving, printing, ..., that is not changed in the form
1604 # Only cvars for now.
1605 sub get_unalterable_data {
1608 foreach my $item (@{ $self->order->items }) {
1609 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1610 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1611 foreach my $var (@{ $item->cvars_by_config }) {
1612 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1614 $item->parse_custom_variable_values;
1620 # And remove related files in the spool directory
1625 my $db = $self->order->db;
1627 $db->with_transaction(
1629 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1630 $self->order->delete;
1631 my $spool = $::lx_office_conf{paths}->{spool};
1632 unlink map { "$spool/$_" } @spoolfiles if $spool;
1635 }) || push(@{$errors}, $db->error);
1642 # And delete items that are deleted in the form.
1647 my $db = $self->order->db;
1649 $db->with_transaction(sub {
1650 # delete custom shipto if it is to be deleted or if it is empty
1651 if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
1652 $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
1653 $self->order->custom_shipto(undef);
1656 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1657 $self->order->save(cascade => 1);
1660 if ($::form->{converted_from_oe_id}) {
1661 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1662 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1663 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1664 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
1665 $src->link_to_record($self->order);
1667 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1669 foreach (@{ $self->order->items_sorted }) {
1670 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1672 SL::DB::RecordLink->new(from_table => 'orderitems',
1673 from_id => $from_id,
1674 to_table => 'orderitems',
1682 }) || push(@{$errors}, $db->error);
1687 sub workflow_sales_or_purchase_order {
1691 my $errors = $self->save();
1693 if (scalar @{ $errors }) {
1694 $self->js->flash('error', $_) foreach @{ $errors };
1695 return $self->js->render();
1698 my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type()
1699 : $::form->{type} eq request_quotation_type() ? purchase_order_type()
1700 : $::form->{type} eq purchase_order_type() ? sales_order_type()
1701 : $::form->{type} eq sales_order_type() ? purchase_order_type()
1704 # check for direct delivery
1705 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
1707 if ( $::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()
1708 && $::form->{use_shipto} && $self->order->shipto) {
1709 $custom_shipto = $self->order->shipto->clone('SL::DB::Order');
1712 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1713 $self->{converted_from_oe_id} = delete $::form->{id};
1715 # set item ids to new fake id, to identify them as new items
1716 foreach my $item (@{$self->order->items_sorted}) {
1717 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1720 if ($::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()) {
1721 if ($::form->{use_shipto}) {
1722 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
1724 # remove any custom shipto if not wanted
1725 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1730 $::form->{type} = $destination_type;
1731 $self->type($self->init_type);
1732 $self->cv ($self->init_cv);
1736 $self->get_unalterable_data();
1737 $self->pre_render();
1739 # trigger rendering values for second row/longdescription as hidden,
1740 # because they are loaded only on demand. So we need to keep the values
1742 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1743 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
1747 title => $self->get_title_for('edit'),
1748 %{$self->{template_args}}
1756 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1757 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
1758 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1759 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1762 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1765 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1767 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1768 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1769 $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
1770 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1771 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1773 my $print_form = Form->new('');
1774 $print_form->{type} = $self->type;
1775 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1776 $print_form->{languages} = SL::DB::Manager::Language->get_all_sorted;
1777 $print_form->{language_id} = $self->order->language_id;
1778 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1779 form => $print_form,
1780 options => {dialog_name_prefix => 'print_options.',
1784 no_opendocument => 0,
1788 foreach my $item (@{$self->order->orderitems}) {
1789 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1790 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1791 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1794 if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
1795 # calculate shipped qtys here to prevent calling calculate for every item via the items method
1796 SL::Helper::ShippedQty->new->calculate($self->order)->write_to_objects;
1799 if ($self->order->number && $::instance_conf->get_webdav) {
1800 my $webdav = SL::Webdav->new(
1801 type => $self->type,
1802 number => $self->order->number,
1804 my @all_objects = $webdav->get_all_objects;
1805 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1807 link => File::Spec->catfile($_->full_filedescriptor),
1811 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1813 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
1814 edit_periodic_invoices_config calculate_qty kivi.Validator);
1815 $self->setup_edit_action_bar;
1818 sub setup_edit_action_bar {
1819 my ($self, %params) = @_;
1821 my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
1822 || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
1823 || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
1825 for my $bar ($::request->layout->get('actionbar')) {
1830 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1831 $::instance_conf->get_order_warn_no_deliverydate,
1833 checks => [ 'kivi.Order.check_save_active_periodic_invoices', ['kivi.validate_form','#order_form'] ],
1837 call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1838 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1839 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1841 ], # end of combobox "Save"
1848 t8('Save and Sales Order'),
1849 submit => [ '#order_form', { action => "Order/sales_order" } ],
1850 only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
1853 t8('Save and Purchase Order'),
1854 call => [ 'kivi.Order.purchase_order_check_for_direct_delivery' ],
1855 only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
1858 t8('Save and Delivery Order'),
1859 call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1860 $::instance_conf->get_order_warn_no_deliverydate,
1862 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1863 only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type()))
1866 t8('Save and Invoice'),
1867 call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1868 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1871 t8('Save and AP Transaction'),
1872 call => [ 'kivi.Order.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
1873 only_if => (any { $self->type eq $_ } (purchase_order_type()))
1876 ], # end of combobox "Workflow"
1883 t8('Save and print'),
1884 call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts ],
1887 t8('Save and E-mail'),
1888 call => [ 'kivi.Order.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts ],
1889 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1892 t8('Download attachments of all parts'),
1893 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1894 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1895 only_if => $::instance_conf->get_doc_storage,
1897 ], # end of combobox "Export"
1901 call => [ 'kivi.Order.delete_order' ],
1902 confirm => $::locale->text('Do you really want to delete this object?'),
1903 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1904 only_if => $deletion_allowed,
1911 my ($order, $pdf_ref, $params) = @_;
1915 my $print_form = Form->new('');
1916 $print_form->{type} = $order->type;
1917 $print_form->{formname} = $params->{formname} || $order->type;
1918 $print_form->{format} = $params->{format} || 'pdf';
1919 $print_form->{media} = $params->{media} || 'file';
1920 $print_form->{groupitems} = $params->{groupitems};
1921 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1923 $order->language($params->{language});
1924 $order->flatten_to_form($print_form, format_amounts => 1);
1928 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
1929 $template_ext = 'odt';
1930 $template_type = 'OpenDocument';
1933 # search for the template
1934 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1935 name => $print_form->{formname},
1936 extension => $template_ext,
1937 email => $print_form->{media} eq 'email',
1938 language => $params->{language},
1939 printer_id => $print_form->{printer_id}, # todo
1942 if (!defined $template_file) {
1943 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);
1946 return @errors if scalar @errors;
1948 $print_form->throw_on_error(sub {
1950 $print_form->prepare_for_printing;
1952 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1953 format => $print_form->{format},
1954 template_type => $template_type,
1955 template => $template_file,
1956 variables => $print_form,
1957 variable_content_types => {
1958 longdescription => 'html',
1959 partnotes => 'html',
1964 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
1970 sub get_files_for_email_dialog {
1973 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
1975 return %files if !$::instance_conf->get_doc_storage;
1977 if ($self->order->id) {
1978 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
1979 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
1980 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
1984 uniq_by { $_->{id} }
1986 +{ id => $_->part->id,
1987 partnumber => $_->part->partnumber }
1988 } @{$self->order->items_sorted};
1990 foreach my $part (@parts) {
1991 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
1992 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
1995 foreach my $key (keys %files) {
1996 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
2002 sub make_periodic_invoices_config_from_yaml {
2003 my ($yaml_config) = @_;
2005 return if !$yaml_config;
2006 my $attr = SL::YAML::Load($yaml_config);
2007 return if 'HASH' ne ref $attr;
2008 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
2012 sub get_periodic_invoices_status {
2013 my ($self, $config) = @_;
2015 return if $self->type ne sales_order_type();
2016 return t8('not configured') if !$config;
2018 my $active = ('HASH' eq ref $config) ? $config->{active}
2019 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
2020 : die "Cannot get status of periodic invoices config";
2022 return $active ? t8('active') : t8('inactive');
2026 my ($self, $action) = @_;
2028 return '' if none { lc($action)} qw(add edit);
2031 # $::locale->text("Add Sales Order");
2032 # $::locale->text("Add Purchase Order");
2033 # $::locale->text("Add Quotation");
2034 # $::locale->text("Add Request for Quotation");
2035 # $::locale->text("Edit Sales Order");
2036 # $::locale->text("Edit Purchase Order");
2037 # $::locale->text("Edit Quotation");
2038 # $::locale->text("Edit Request for Quotation");
2040 $action = ucfirst(lc($action));
2041 return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order")
2042 : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order")
2043 : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation")
2044 : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
2048 sub get_item_cvpartnumber {
2049 my ($self, $item) = @_;
2051 return if !$self->search_cvpartnumber;
2052 return if !$self->order->customervendor;
2054 if ($self->cv eq 'vendor') {
2055 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
2056 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
2057 } elsif ($self->cv eq 'customer') {
2058 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
2059 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
2063 sub sales_order_type {
2067 sub purchase_order_type {
2071 sub sales_quotation_type {
2075 sub request_quotation_type {
2076 'request_quotation';
2080 return $_[0]->type eq sales_order_type() ? 'ordnumber'
2081 : $_[0]->type eq purchase_order_type() ? 'ordnumber'
2082 : $_[0]->type eq sales_quotation_type() ? 'quonumber'
2083 : $_[0]->type eq request_quotation_type() ? 'quonumber'
2095 SL::Controller::Order - controller for orders
2099 This is a new form to enter orders, completely rewritten with the use
2100 of controller and java script techniques.
2102 The aim is to provide the user a better experience and a faster workflow. Also
2103 the code should be more readable, more reliable and better to maintain.
2111 One input row, so that input happens every time at the same place.
2115 Use of pickers where possible.
2119 Possibility to enter more than one item at once.
2123 Item list in a scrollable area, so that the workflow buttons stay at
2128 Reordering item rows with drag and drop is possible. Sorting item rows is
2129 possible (by partnumber, description, qty, sellprice and discount for now).
2133 No C<update> is necessary. All entries and calculations are managed
2134 with ajax-calls and the page only reloads on C<save>.
2138 User can see changes immediately, because of the use of java script
2149 =item * C<SL/Controller/Order.pm>
2153 =item * C<template/webpages/order/form.html>
2157 =item * C<template/webpages/order/tabs/basic_data.html>
2159 Main tab for basic_data.
2161 This is the only tab here for now. "linked records" and "webdav" tabs are
2162 reused from generic code.
2166 =item * C<template/webpages/order/tabs/_business_info_row.html>
2168 For displaying information on business type
2170 =item * C<template/webpages/order/tabs/_item_input.html>
2172 The input line for items
2174 =item * C<template/webpages/order/tabs/_row.html>
2176 One row for already entered items
2178 =item * C<template/webpages/order/tabs/_tax_row.html>
2180 Displaying tax information
2182 =item * C<template/webpages/order/tabs/_multi_items_dialog.html>
2184 Dialog for entering more than one item at once
2186 =item * C<template/webpages/order/tabs/_multi_items_result.html>
2188 Results for the filter in the multi items dialog
2190 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
2192 Dialog for selecting price and discount sources
2196 =item * C<js/kivi.Order.js>
2198 java script functions
2208 =item * credit limit
2210 =item * more workflows (quotation, rfq)
2212 =item * price sources: little symbols showing better price / better discount
2214 =item * select units in input row?
2216 =item * check for direct delivery (workflow sales order -> purchase order)
2218 =item * language / part translations
2220 =item * access rights
2222 =item * display weights
2228 =item * optional client/user behaviour
2230 (transactions has to be set - department has to be set -
2231 force project if enabled in client config - transport cost reminder)
2235 =head1 KNOWN BUGS AND CAVEATS
2241 Customer discount is not displayed as a valid discount in price source popup
2242 (this might be a bug in price sources)
2244 (I cannot reproduce this (Bernd))
2248 No indication that <shift>-up/down expands/collapses second row.
2252 Inline creation of parts is not currently supported
2256 Table header is not sticky in the scrolling area.
2260 Sorting does not include C<position>, neither does reordering.
2262 This behavior was implemented intentionally. But we can discuss, which behavior
2263 should be implemented.
2267 C<show_multi_items_dialog> does not use the currently inserted string for
2272 =head1 To discuss / Nice to have
2278 How to expand/collapse second row. Now it can be done clicking the icon or
2283 Possibility to change longdescription in input row?
2287 Possibility to select PriceSources in input row?
2291 This controller uses a (changed) copy of the template for the PriceSource
2292 dialog. Maybe there could be used one code source.
2296 Rounding-differences between this controller (PriceTaxCalculator) and the old
2297 form. This is not only a problem here, but also in all parts using the PTC.
2298 There exists a ticket and a patch. This patch should be testet.
2302 An indicator, if the actual inputs are saved (like in an
2303 editor or on text processing application).
2307 A warning when leaving the page without saveing unchanged inputs.
2314 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>