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_show_email_dialog {
356 my $cv_method = $self->cv;
358 if (!$self->order->$cv_method) {
359 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'))
364 $email_form->{to} = $self->order->contact->cp_email if $self->order->contact;
365 $email_form->{to} ||= $self->order->$cv_method->email;
366 $email_form->{cc} = $self->order->$cv_method->cc;
367 $email_form->{bcc} = join ', ', grep $_, $self->order->$cv_method->bcc, SL::DB::Default->get->global_bcc;
368 # Todo: get addresses from shipto, if any
370 my $form = Form->new;
371 $form->{$self->nr_key()} = $self->order->number;
372 $form->{formname} = $self->type;
373 $form->{type} = $self->type;
374 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
375 $form->{language_id} = $self->order->language->id if $self->order->language;
376 $form->{cusordnumber} = $self->order->cusordnumber;
377 $form->{format} = 'pdf';
379 $email_form->{subject} = $form->generate_email_subject();
380 $email_form->{attachment_filename} = $form->generate_attachment_filename();
381 $email_form->{message} = $form->generate_email_body();
382 $email_form->{js_send_function} = 'kivi.Order.send_email()';
384 my %files = $self->get_files_for_email_dialog();
385 my $dialog_html = $self->render('common/_send_email_dialog', { output => 0 },
386 email_form => $email_form,
387 show_bcc => $::auth->assert('email_bcc', 'may fail'),
389 is_customer => $self->cv eq 'customer',
393 ->run('kivi.Order.show_email_dialog', $dialog_html)
400 # Todo: handling error messages: flash is not displayed in dialog, but in the main form
401 sub action_send_email {
404 my $errors = $self->save();
406 if (scalar @{ $errors }) {
407 $self->js->run('kivi.Order.close_email_dialog');
408 $self->js->flash('error', $_) foreach @{ $errors };
409 return $self->js->render();
412 $self->js_reset_order_and_item_ids_after_save;
414 my $email_form = delete $::form->{email_form};
415 my %field_names = (to => 'email');
417 $::form->{ $field_names{$_} // $_ } = $email_form->{$_} for keys %{ $email_form };
419 # for Form::cleanup which may be called in Form::send_email
420 $::form->{cwd} = getcwd();
421 $::form->{tmpdir} = $::lx_office_conf{paths}->{userspath};
423 $::form->{$_} = $::form->{print_options}->{$_} for keys %{ $::form->{print_options} };
424 $::form->{media} = 'email';
426 if (($::form->{attachment_policy} // '') !~ m{^(?:old_file|no_file)$}) {
428 $language = SL::DB::Language->new(id => $::form->{print_options}->{language_id})->load if $::form->{print_options}->{language_id};
431 my @errors = generate_pdf($self->order, \$pdf, {media => $::form->{media},
432 format => $::form->{print_options}->{format},
433 formname => $::form->{print_options}->{formname},
434 language => $language,
435 groupitems => $::form->{print_options}->{groupitems}});
436 if (scalar @errors) {
437 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
440 my $sfile = SL::SessionFile::Random->new(mode => "w");
441 $sfile->fh->print($pdf);
444 $::form->{tmpfile} = $sfile->file_name;
445 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
448 $::form->send_email(\%::myconfig, 'pdf');
451 my $intnotes = $self->order->intnotes;
452 $intnotes .= "\n\n" if $self->order->intnotes;
453 $intnotes .= t8('[email]') . "\n";
454 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
455 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
456 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
457 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
458 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
459 $intnotes .= t8('Message') . ": " . $::form->{message};
461 $self->order->update_attributes(intnotes => $intnotes);
464 ->val('#order_intnotes', $intnotes)
465 ->run('kivi.Order.close_email_dialog')
466 ->flash('info', t8('The email has been sent.'))
470 # open the periodic invoices config dialog
472 # If there are values in the form (i.e. dialog was opened before),
473 # then use this values. Create new ones, else.
474 sub action_show_periodic_invoices_config_dialog {
477 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
478 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
479 $config ||= SL::DB::PeriodicInvoicesConfig->new(periodicity => 'm',
480 order_value_periodicity => 'p', # = same as periodicity
481 start_date_as_date => $::form->{transdate_as_date} || $::form->current_date,
482 extend_automatically_by => 12,
484 email_subject => GenericTranslations->get(
485 language_id => $::form->{language_id},
486 translation_type =>"preset_text_periodic_invoices_email_subject"),
487 email_body => GenericTranslations->get(
488 language_id => $::form->{language_id},
489 translation_type =>"preset_text_periodic_invoices_email_body"),
491 $config->periodicity('m') if none { $_ eq $config->periodicity } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
492 $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
494 $::form->get_lists(printers => "ALL_PRINTERS",
495 charts => { key => 'ALL_CHARTS',
496 transdate => 'current_date' });
498 $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
500 if ($::form->{customer_id}) {
501 $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
502 $::form->{email_recipient_invoice_address} = SL::DB::Manager::Customer->find_by(id => $::form->{customer_id})->invoice_mail;
505 $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
507 popup_js_close_function => 'kivi.Order.close_periodic_invoices_config_dialog()',
508 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
513 # assign the values of the periodic invoices config dialog
514 # as yaml in the hidden tag and set the status.
515 sub action_assign_periodic_invoices_config {
518 $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
520 my $config = { active => $::form->{active} ? 1 : 0,
521 terminated => $::form->{terminated} ? 1 : 0,
522 direct_debit => $::form->{direct_debit} ? 1 : 0,
523 periodicity => (any { $_ eq $::form->{periodicity} } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES) ? $::form->{periodicity} : 'm',
524 order_value_periodicity => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
525 start_date_as_date => $::form->{start_date_as_date},
526 end_date_as_date => $::form->{end_date_as_date},
527 first_billing_date_as_date => $::form->{first_billing_date_as_date},
528 print => $::form->{print} ? 1 : 0,
529 printer_id => $::form->{print} ? $::form->{printer_id} * 1 : undef,
530 copies => $::form->{copies} * 1 ? $::form->{copies} : 1,
531 extend_automatically_by => $::form->{extend_automatically_by} * 1 || undef,
532 ar_chart_id => $::form->{ar_chart_id} * 1,
533 send_email => $::form->{send_email} ? 1 : 0,
534 email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
535 email_recipient_address => $::form->{email_recipient_address},
536 email_sender => $::form->{email_sender},
537 email_subject => $::form->{email_subject},
538 email_body => $::form->{email_body},
541 my $periodic_invoices_config = SL::YAML::Dump($config);
543 my $status = $self->get_periodic_invoices_status($config);
546 ->remove('#order_periodic_invoices_config')
547 ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
548 ->run('kivi.Order.close_periodic_invoices_config_dialog')
549 ->html('#periodic_invoices_status', $status)
550 ->flash('info', t8('The periodic invoices config has been assigned.'))
554 sub action_get_has_active_periodic_invoices {
557 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
558 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
560 my $has_active_periodic_invoices =
561 $self->type eq sales_order_type()
564 && (!$config->end_date || ($config->end_date > DateTime->today_local))
565 && $config->get_previous_billed_period_start_date;
567 $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
570 # save the order and redirect to the frontend subroutine for a new
572 sub action_save_and_delivery_order {
575 my $errors = $self->save();
577 if (scalar @{ $errors }) {
578 $self->js->flash('error', $_) foreach @{ $errors };
579 return $self->js->render();
582 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
583 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
584 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
585 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
587 flash_later('info', $text);
589 my @redirect_params = (
590 controller => 'oe.pl',
591 action => 'oe_delivery_order_from_order',
592 id => $self->order->id,
595 $self->redirect_to(@redirect_params);
598 # save the order and redirect to the frontend subroutine for a new
600 sub action_save_and_invoice {
603 my $errors = $self->save();
605 if (scalar @{ $errors }) {
606 $self->js->flash('error', $_) foreach @{ $errors };
607 return $self->js->render();
610 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
611 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
612 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
613 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
615 flash_later('info', $text);
617 my @redirect_params = (
618 controller => 'oe.pl',
619 action => 'oe_invoice_from_order',
620 id => $self->order->id,
623 $self->redirect_to(@redirect_params);
626 # workflow from sales quotation to sales order
627 sub action_sales_order {
628 $_[0]->workflow_sales_or_purchase_order();
631 # workflow from rfq to purchase order
632 sub action_purchase_order {
633 $_[0]->workflow_sales_or_purchase_order();
636 # workflow from purchase order to ap transaction
637 sub action_save_and_ap_transaction {
640 my $errors = $self->save();
642 if (scalar @{ $errors }) {
643 $self->js->flash('error', $_) foreach @{ $errors };
644 return $self->js->render();
647 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
648 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
649 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
650 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
652 flash_later('info', $text);
654 my @redirect_params = (
655 controller => 'ap.pl',
656 action => 'add_from_purchase_order',
657 id => $self->order->id,
660 $self->redirect_to(@redirect_params);
663 # set form elements in respect to a changed customer or vendor
665 # This action is called on an change of the customer/vendor picker.
666 sub action_customer_vendor_changed {
669 setup_order_from_cv($self->order);
672 my $cv_method = $self->cv;
674 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
675 $self->js->show('#cp_row');
677 $self->js->hide('#cp_row');
680 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
681 $self->js->show('#shipto_selection');
683 $self->js->hide('#shipto_selection');
686 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
689 ->replaceWith('#order_cp_id', $self->build_contact_select)
690 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
691 ->replaceWith('#shipto_inputs ', $self->build_shipto_inputs)
692 ->replaceWith('#business_info_row', $self->build_business_info_row)
693 ->val( '#order_taxzone_id', $self->order->taxzone_id)
694 ->val( '#order_taxincluded', $self->order->taxincluded)
695 ->val( '#order_currency_id', $self->order->currency_id)
696 ->val( '#order_payment_id', $self->order->payment_id)
697 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
698 ->val( '#order_intnotes', $self->order->intnotes)
699 ->val( '#language_id', $self->order->$cv_method->language_id)
700 ->focus( '#order_' . $self->cv . '_id')
701 ->run('kivi.Order.update_exchangerate');
703 $self->js_redisplay_amounts_and_taxes;
704 $self->js_redisplay_cvpartnumbers;
708 # open the dialog for customer/vendor details
709 sub action_show_customer_vendor_details_dialog {
712 my $is_customer = 'customer' eq $::form->{vc};
715 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
717 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
720 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
721 $details{discount_as_percent} = $cv->discount_as_percent;
722 $details{creditlimt} = $cv->creditlimit_as_number;
723 $details{business} = $cv->business->description if $cv->business;
724 $details{language} = $cv->language_obj->description if $cv->language_obj;
725 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
726 $details{payment_terms} = $cv->payment->description if $cv->payment;
727 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
729 foreach my $entry (@{ $cv->shipto }) {
730 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
732 foreach my $entry (@{ $cv->contacts }) {
733 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
736 $_[0]->render('common/show_vc_details', { layout => 0 },
737 is_customer => $is_customer,
742 # called if a unit in an existing item row is changed
743 sub action_unit_changed {
746 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
747 my $item = $self->order->items_sorted->[$idx];
749 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
750 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
755 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
756 $self->js_redisplay_line_values;
757 $self->js_redisplay_amounts_and_taxes;
761 # add an item row for a new item entered in the input row
762 sub action_add_item {
765 my $form_attr = $::form->{add_item};
767 return unless $form_attr->{parts_id};
769 my $item = new_item($self->order, $form_attr);
771 $self->order->add_items($item);
775 $self->get_item_cvpartnumber($item);
777 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
778 my $row_as_html = $self->p->render('order/tabs/_row',
784 if ($::form->{insert_before_item_id}) {
786 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
789 ->append('#row_table_id', $row_as_html);
792 if ( $item->part->is_assortment ) {
793 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
794 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
795 my $attr = { parts_id => $assortment_item->parts_id,
796 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
797 unit => $assortment_item->unit,
798 description => $assortment_item->part->description,
800 my $item = new_item($self->order, $attr);
802 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
803 $item->discount(1) unless $assortment_item->charge;
805 $self->order->add_items( $item );
807 $self->get_item_cvpartnumber($item);
808 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
809 my $row_as_html = $self->p->render('order/tabs/_row',
814 if ($::form->{insert_before_item_id}) {
816 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
819 ->append('#row_table_id', $row_as_html);
825 ->val('.add_item_input', '')
826 ->run('kivi.Order.init_row_handlers')
827 ->run('kivi.Order.renumber_positions')
828 ->focus('#add_item_parts_id_name');
830 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
832 $self->js_redisplay_amounts_and_taxes;
836 # open the dialog for entering multiple items at once
837 sub action_show_multi_items_dialog {
838 $_[0]->render('order/tabs/_multi_items_dialog', { layout => 0 },
839 all_partsgroups => SL::DB::Manager::PartsGroup->get_all);
842 # update the filter results in the multi item dialog
843 sub action_multi_items_update_result {
846 $::form->{multi_items}->{filter}->{obsolete} = 0;
848 my $count = $_[0]->multi_items_models->count;
851 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('No results.'));
852 $_[0]->render($text, { layout => 0 });
853 } elsif ($count > $max_count) {
854 my $text = SL::Presenter::EscapedText->new(text => $::locale->text('Too many results (#1 from #2).', $count, $max_count));
855 $_[0]->render($text, { layout => 0 });
857 my $multi_items = $_[0]->multi_items_models->get;
858 $_[0]->render('order/tabs/_multi_items_result', { layout => 0 },
859 multi_items => $multi_items);
863 # add item rows for multiple items at once
864 sub action_add_multi_items {
867 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_multi_items} };
868 return $self->js->render() unless scalar @form_attr;
871 foreach my $attr (@form_attr) {
872 my $item = new_item($self->order, $attr);
874 if ( $item->part->is_assortment ) {
875 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
876 my $attr = { parts_id => $assortment_item->parts_id,
877 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
878 unit => $assortment_item->unit,
879 description => $assortment_item->part->description,
881 my $item = new_item($self->order, $attr);
883 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
884 $item->discount(1) unless $assortment_item->charge;
889 $self->order->add_items(@items);
893 foreach my $item (@items) {
894 $self->get_item_cvpartnumber($item);
895 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
896 my $row_as_html = $self->p->render('order/tabs/_row',
902 if ($::form->{insert_before_item_id}) {
904 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
907 ->append('#row_table_id', $row_as_html);
912 ->run('kivi.Order.close_multi_items_dialog')
913 ->run('kivi.Order.init_row_handlers')
914 ->run('kivi.Order.renumber_positions')
915 ->focus('#add_item_parts_id_name');
917 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
919 $self->js_redisplay_amounts_and_taxes;
923 # recalculate all linetotals, amounts and taxes and redisplay them
924 sub action_recalc_amounts_and_taxes {
929 $self->js_redisplay_line_values;
930 $self->js_redisplay_amounts_and_taxes;
934 sub action_update_exchangerate {
938 is_standard => $self->order->currency_id == $::instance_conf->get_currency_id,
939 currency_name => $self->order->currency->name,
940 exchangerate => $self->order->daily_exchangerate_as_null_number,
943 $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
946 # redisplay item rows if they are sorted by an attribute
947 sub action_reorder_items {
951 partnumber => sub { $_[0]->part->partnumber },
952 description => sub { $_[0]->description },
953 qty => sub { $_[0]->qty },
954 sellprice => sub { $_[0]->sellprice },
955 discount => sub { $_[0]->discount },
956 cvpartnumber => sub { $_[0]->{cvpartnumber} },
959 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
961 my $method = $sort_keys{$::form->{order_by}};
962 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
963 if ($::form->{sort_dir}) {
964 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
965 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
967 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
970 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
971 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
973 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
977 ->run('kivi.Order.redisplay_items', \@to_sort)
981 # show the popup to choose a price/discount source
982 sub action_price_popup {
985 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
986 my $item = $self->order->items_sorted->[$idx];
988 $self->render_price_dialog($item);
991 # get the longdescription for an item if the dialog to enter/change the
992 # longdescription was opened and the longdescription is empty
994 # If this item is new, get the longdescription from Part.
995 # Otherwise get it from OrderItem.
996 sub action_get_item_longdescription {
999 if ($::form->{item_id}) {
1000 $longdescription = SL::DB::OrderItem->new(id => $::form->{item_id})->load->longdescription;
1001 } elsif ($::form->{parts_id}) {
1002 $longdescription = SL::DB::Part->new(id => $::form->{parts_id})->load->notes;
1004 $_[0]->render(\ $longdescription, { type => 'text' });
1007 # load the second row for one or more items
1009 # This action gets the html code for all items second rows by rendering a template for
1010 # the second row and sets the html code via client js.
1011 sub action_load_second_rows {
1014 $self->recalc() if $self->order->is_sales; # for margin calculation
1016 foreach my $item_id (@{ $::form->{item_ids} }) {
1017 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1018 my $item = $self->order->items_sorted->[$idx];
1020 $self->js_load_second_row($item, $item_id, 0);
1023 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
1025 $self->js->render();
1028 # update description, notes and sellprice from master data
1029 sub action_update_row_from_master_data {
1032 foreach my $item_id (@{ $::form->{item_ids} }) {
1033 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
1034 my $item = $self->order->items_sorted->[$idx];
1036 $item->description($item->part->description);
1037 $item->longdescription($item->part->notes);
1039 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1042 if ($item->part->is_assortment) {
1043 # add assortment items with price 0, as the components carry the price
1044 $price_src = $price_source->price_from_source("");
1045 $price_src->price(0);
1047 $price_src = $price_source->best_price
1048 ? $price_source->best_price
1049 : $price_source->price_from_source("");
1050 $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate;
1051 $price_src->price(0) if !$price_source->best_price;
1055 $item->sellprice($price_src->price);
1056 $item->active_price_source($price_src);
1059 ->run('kivi.Order.update_sellprice', $item_id, $item->sellprice_as_number)
1060 ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
1061 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
1062 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
1064 if ($self->search_cvpartnumber) {
1065 $self->get_item_cvpartnumber($item);
1066 $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
1071 $self->js_redisplay_line_values;
1072 $self->js_redisplay_amounts_and_taxes;
1074 $self->js->render();
1077 sub js_load_second_row {
1078 my ($self, $item, $item_id, $do_parse) = @_;
1081 # Parse values from form (they are formated while rendering (template)).
1082 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1083 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1084 foreach my $var (@{ $item->cvars_by_config }) {
1085 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1087 $item->parse_custom_variable_values;
1090 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1093 ->html('#second_row_' . $item_id, $row_as_html)
1094 ->data('#second_row_' . $item_id, 'loaded', 1);
1097 sub js_redisplay_line_values {
1100 my $is_sales = $self->order->is_sales;
1102 # sales orders with margins
1107 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1108 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1109 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1110 ]} @{ $self->order->items_sorted };
1114 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1115 ]} @{ $self->order->items_sorted };
1119 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1122 sub js_redisplay_amounts_and_taxes {
1125 if (scalar @{ $self->{taxes} }) {
1126 $self->js->show('#taxincluded_row_id');
1128 $self->js->hide('#taxincluded_row_id');
1131 if ($self->order->taxincluded) {
1132 $self->js->hide('#subtotal_row_id');
1134 $self->js->show('#subtotal_row_id');
1137 if ($self->order->is_sales) {
1138 my $is_neg = $self->order->marge_total < 0;
1140 ->html('#marge_total_id', $::form->format_amount(\%::myconfig, $self->order->marge_total, 2))
1141 ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
1142 ->action_if( $is_neg, 'addClass', '#marge_total_id', 'plus0')
1143 ->action_if( $is_neg, 'addClass', '#marge_percent_id', 'plus0')
1144 ->action_if( $is_neg, 'addClass', '#marge_percent_sign_id', 'plus0')
1145 ->action_if(!$is_neg, 'removeClass', '#marge_total_id', 'plus0')
1146 ->action_if(!$is_neg, 'removeClass', '#marge_percent_id', 'plus0')
1147 ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
1151 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
1152 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
1153 ->remove('.tax_row')
1154 ->insertBefore($self->build_tax_rows, '#amount_row_id');
1157 sub js_redisplay_cvpartnumbers {
1160 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1162 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1165 ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
1168 sub js_reset_order_and_item_ids_after_save {
1172 ->val('#id', $self->order->id)
1173 ->val('#converted_from_oe_id', '')
1174 ->val('#order_' . $self->nr_key(), $self->order->number);
1177 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1178 next if !$self->order->items_sorted->[$idx]->id;
1179 next if $form_item_id !~ m{^new};
1181 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1182 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1183 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1187 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1194 sub init_valid_types {
1195 [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
1201 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1202 die "Not a valid type for order";
1205 $self->type($::form->{type});
1211 my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer'
1212 : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
1213 : die "Not a valid type for order";
1218 sub init_search_cvpartnumber {
1221 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1222 my $search_cvpartnumber;
1223 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1224 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1226 return $search_cvpartnumber;
1229 sub init_show_update_button {
1232 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1243 # model used to filter/display the parts in the multi-items dialog
1244 sub init_multi_items_models {
1245 SL::Controller::Helper::GetModels->new(
1246 controller => $_[0],
1248 with_objects => [ qw(unit_obj) ],
1249 disable_plugin => 'paginated',
1250 source => $::form->{multi_items},
1256 partnumber => t8('Partnumber'),
1257 description => t8('Description')}
1261 sub init_all_price_factors {
1262 SL::DB::Manager::PriceFactor->get_all;
1268 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
1270 my $right = $right_for->{ $self->type };
1271 $right ||= 'DOES_NOT_EXIST';
1273 $::auth->assert($right);
1276 # build the selection box for contacts
1278 # Needed, if customer/vendor changed.
1279 sub build_contact_select {
1282 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1283 value_key => 'cp_id',
1284 title_key => 'full_name_dep',
1285 default => $self->order->cp_id,
1287 style => 'width: 300px',
1291 # build the selection box for shiptos
1293 # Needed, if customer/vendor changed.
1294 sub build_shipto_select {
1297 select_tag('order.shipto_id',
1298 [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
1299 value_key => 'shipto_id',
1300 title_key => 'displayable_id',
1301 default => $self->order->shipto_id,
1303 style => 'width: 300px',
1307 # build the inputs for the cusom shipto dialog
1309 # Needed, if customer/vendor changed.
1310 sub build_shipto_inputs {
1313 my $content = $self->p->render('common/_ship_to_dialog',
1314 vc_obj => $self->order->customervendor,
1315 cs_obj => $self->order->custom_shipto,
1316 cvars => $self->order->custom_shipto->cvars_by_config,
1317 id_selector => '#order_shipto_id');
1319 div_tag($content, id => 'shipto_inputs');
1322 # render the info line for business
1324 # Needed, if customer/vendor changed.
1325 sub build_business_info_row
1327 $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
1330 # build the rows for displaying taxes
1332 # Called if amounts where recalculated and redisplayed.
1333 sub build_tax_rows {
1337 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
1338 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
1340 return $rows_as_html;
1344 sub render_price_dialog {
1345 my ($self, $record_item) = @_;
1347 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1351 'kivi.io.price_chooser_dialog',
1352 t8('Available Prices'),
1353 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1358 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1359 # $self->js->show('#dialog_flash_error');
1368 return if !$::form->{id};
1370 $self->order(SL::DB::Order->new(id => $::form->{id})->load);
1372 # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
1373 # You need a custom shipto object to call cvars_by_config to get the cvars.
1374 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
1376 return $self->order;
1379 # load or create a new order object
1381 # And assign changes from the form to this object.
1382 # If the order is loaded from db, check if items are deleted in the form,
1383 # remove them form the object and collect them for removing from db on saving.
1384 # Then create/update items from form (via make_item) and add them.
1388 # add_items adds items to an order with no items for saving, but they cannot
1389 # be retrieved via items until the order is saved. Adding empty items to new
1390 # order here solves this problem.
1392 $order = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1393 $order ||= SL::DB::Order->new(orderitems => [],
1394 quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())),
1395 currency_id => $::instance_conf->get_currency_id(),);
1397 my $cv_id_method = $self->cv . '_id';
1398 if (!$::form->{id} && $::form->{$cv_id_method}) {
1399 $order->$cv_id_method($::form->{$cv_id_method});
1400 setup_order_from_cv($order);
1403 my $form_orderitems = delete $::form->{order}->{orderitems};
1404 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1406 $order->assign_attributes(%{$::form->{order}});
1408 $self->setup_custom_shipto_from_form($order, $::form);
1410 if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) {
1411 my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
1412 $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
1415 # remove deleted items
1416 $self->item_ids_to_delete([]);
1417 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1418 my $item = $order->orderitems->[$idx];
1419 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1420 splice @{$order->orderitems}, $idx, 1;
1421 push @{$self->item_ids_to_delete}, $item->id;
1427 foreach my $form_attr (@{$form_orderitems}) {
1428 my $item = make_item($order, $form_attr);
1429 $item->position($pos);
1433 $order->add_items(grep {!$_->id} @items);
1438 # create or update items from form
1440 # Make item objects from form values. For items already existing read from db.
1441 # Create a new item else. And assign attributes.
1443 my ($record, $attr) = @_;
1446 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1448 my $is_new = !$item;
1450 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1451 # they cannot be retrieved via custom_variables until the order/orderitem is
1452 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1453 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1455 $item->assign_attributes(%$attr);
1456 $item->longdescription($item->part->notes) if $is_new && !defined $attr->{longdescription};
1457 $item->project_id($record->globalproject_id) if $is_new && !defined $attr->{project_id};
1458 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if $is_new && !defined $attr->{lastcost_as_number};
1465 # This is used to add one item
1467 my ($record, $attr) = @_;
1469 my $item = SL::DB::OrderItem->new;
1471 # Remove attributes where the user left or set the inputs empty.
1472 # So these attributes will be undefined and we can distinguish them
1473 # from zero later on.
1474 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1475 delete $attr->{$_} if $attr->{$_} eq '';
1478 $item->assign_attributes(%$attr);
1480 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1481 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1483 $item->unit($part->unit) if !$item->unit;
1486 if ( $part->is_assortment ) {
1487 # add assortment items with price 0, as the components carry the price
1488 $price_src = $price_source->price_from_source("");
1489 $price_src->price(0);
1490 } elsif (defined $item->sellprice) {
1491 $price_src = $price_source->price_from_source("");
1492 $price_src->price($item->sellprice);
1494 $price_src = $price_source->best_price
1495 ? $price_source->best_price
1496 : $price_source->price_from_source("");
1497 $price_src->price($::form->round_amount($price_src->price / $record->exchangerate, 5)) if $record->exchangerate;
1498 $price_src->price(0) if !$price_source->best_price;
1502 if (defined $item->discount) {
1503 $discount_src = $price_source->discount_from_source("");
1504 $discount_src->discount($item->discount);
1506 $discount_src = $price_source->best_discount
1507 ? $price_source->best_discount
1508 : $price_source->discount_from_source("");
1509 $discount_src->discount(0) if !$price_source->best_discount;
1513 $new_attr{part} = $part;
1514 $new_attr{description} = $part->description if ! $item->description;
1515 $new_attr{qty} = 1.0 if ! $item->qty;
1516 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1517 $new_attr{sellprice} = $price_src->price;
1518 $new_attr{discount} = $discount_src->discount;
1519 $new_attr{active_price_source} = $price_src;
1520 $new_attr{active_discount_source} = $discount_src;
1521 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1522 $new_attr{project_id} = $record->globalproject_id;
1523 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1525 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1526 # they cannot be retrieved via custom_variables until the order/orderitem is
1527 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1528 $new_attr{custom_variables} = [];
1530 $item->assign_attributes(%new_attr);
1535 sub setup_order_from_cv {
1538 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id));
1540 $order->intnotes($order->customervendor->notes);
1542 if ($order->is_sales) {
1543 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1544 $order->taxincluded(defined($order->customer->taxincluded_checked)
1545 ? $order->customer->taxincluded_checked
1546 : $::myconfig{taxincluded_checked});
1551 # setup custom shipto from form
1553 # The dialog returns form variables starting with 'shipto' and cvars starting
1554 # with 'shiptocvar_'.
1555 # Mark it to be deleted if a shipto from master data is selected
1556 # (i.e. order has a shipto).
1557 # Else, update or create a new custom shipto. If the fields are empty, it
1558 # will not be saved on save.
1559 sub setup_custom_shipto_from_form {
1560 my ($self, $order, $form) = @_;
1562 if ($order->shipto) {
1563 $self->is_custom_shipto_to_delete(1);
1565 my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1567 my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
1568 my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
1570 $custom_shipto->assign_attributes(%$shipto_attrs);
1571 $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
1575 # recalculate prices and taxes
1577 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1581 my %pat = $self->order->calculate_prices_and_taxes();
1583 $self->{taxes} = [];
1584 foreach my $tax_id (keys %{ $pat{taxes_by_tax_id} }) {
1585 my $netamount = sum0 map { $pat{amounts}->{$_}->{amount} } grep { $pat{amounts}->{$_}->{tax_id} == $tax_id } keys %{ $pat{amounts} };
1587 push(@{ $self->{taxes} }, { amount => $pat{taxes_by_tax_id}->{$tax_id},
1588 netamount => $netamount,
1589 tax => SL::DB::Tax->new(id => $tax_id)->load });
1591 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items_sorted}, @{$pat{items}};
1594 # get data for saving, printing, ..., that is not changed in the form
1596 # Only cvars for now.
1597 sub get_unalterable_data {
1600 foreach my $item (@{ $self->order->items }) {
1601 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1602 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1603 foreach my $var (@{ $item->cvars_by_config }) {
1604 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1606 $item->parse_custom_variable_values;
1612 # And remove related files in the spool directory
1617 my $db = $self->order->db;
1619 $db->with_transaction(
1621 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1622 $self->order->delete;
1623 my $spool = $::lx_office_conf{paths}->{spool};
1624 unlink map { "$spool/$_" } @spoolfiles if $spool;
1627 }) || push(@{$errors}, $db->error);
1634 # And delete items that are deleted in the form.
1639 my $db = $self->order->db;
1641 $db->with_transaction(sub {
1642 # delete custom shipto if it is to be deleted or if it is empty
1643 if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
1644 $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
1645 $self->order->custom_shipto(undef);
1648 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1649 $self->order->save(cascade => 1);
1652 if ($::form->{converted_from_oe_id}) {
1653 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1654 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1655 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1656 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
1657 $src->link_to_record($self->order);
1659 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1661 foreach (@{ $self->order->items_sorted }) {
1662 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1664 SL::DB::RecordLink->new(from_table => 'orderitems',
1665 from_id => $from_id,
1666 to_table => 'orderitems',
1674 }) || push(@{$errors}, $db->error);
1679 sub workflow_sales_or_purchase_order {
1683 my $errors = $self->save();
1685 if (scalar @{ $errors }) {
1686 $self->js->flash('error', $_) foreach @{ $errors };
1687 return $self->js->render();
1690 my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type()
1691 : $::form->{type} eq request_quotation_type() ? purchase_order_type()
1692 : $::form->{type} eq purchase_order_type() ? sales_order_type()
1693 : $::form->{type} eq sales_order_type() ? purchase_order_type()
1696 # check for direct delivery
1697 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
1699 if ( $::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()
1700 && $::form->{use_shipto} && $self->order->shipto) {
1701 $custom_shipto = $self->order->shipto->clone('SL::DB::Order');
1704 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1705 $self->{converted_from_oe_id} = delete $::form->{id};
1707 # set item ids to new fake id, to identify them as new items
1708 foreach my $item (@{$self->order->items_sorted}) {
1709 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1712 if ($::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()) {
1713 if ($::form->{use_shipto}) {
1714 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
1716 # remove any custom shipto if not wanted
1717 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1722 $::form->{type} = $destination_type;
1723 $self->type($self->init_type);
1724 $self->cv ($self->init_cv);
1728 $self->get_unalterable_data();
1729 $self->pre_render();
1731 # trigger rendering values for second row/longdescription as hidden,
1732 # because they are loaded only on demand. So we need to keep the values
1734 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1735 $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
1739 title => $self->get_title_for('edit'),
1740 %{$self->{template_args}}
1748 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1749 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
1750 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1751 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1754 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1757 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1759 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1760 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1761 $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
1762 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1763 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1765 my $print_form = Form->new('');
1766 $print_form->{type} = $self->type;
1767 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1768 $print_form->{languages} = SL::DB::Manager::Language->get_all_sorted;
1769 $print_form->{language_id} = $self->order->language_id;
1770 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1771 form => $print_form,
1772 options => {dialog_name_prefix => 'print_options.',
1776 no_opendocument => 0,
1780 foreach my $item (@{$self->order->orderitems}) {
1781 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1782 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1783 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1786 if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
1787 # calculate shipped qtys here to prevent calling calculate for every item via the items method
1788 SL::Helper::ShippedQty->new->calculate($self->order)->write_to_objects;
1791 if ($self->order->number && $::instance_conf->get_webdav) {
1792 my $webdav = SL::Webdav->new(
1793 type => $self->type,
1794 number => $self->order->number,
1796 my @all_objects = $webdav->get_all_objects;
1797 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1799 link => File::Spec->catfile($_->full_filedescriptor),
1803 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1805 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
1806 edit_periodic_invoices_config calculate_qty kivi.Validator);
1807 $self->setup_edit_action_bar;
1810 sub setup_edit_action_bar {
1811 my ($self, %params) = @_;
1813 my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
1814 || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
1815 || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
1817 for my $bar ($::request->layout->get('actionbar')) {
1822 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1823 $::instance_conf->get_order_warn_no_deliverydate,
1825 checks => [ 'kivi.Order.check_save_active_periodic_invoices', ['kivi.validate_form','#order_form'] ],
1829 call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1830 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1831 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1833 ], # end of combobox "Save"
1840 t8('Save and Sales Order'),
1841 submit => [ '#order_form', { action => "Order/sales_order" } ],
1842 only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
1845 t8('Save and Purchase Order'),
1846 call => [ 'kivi.Order.purchase_order_check_for_direct_delivery' ],
1847 only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
1850 t8('Save and Delivery Order'),
1851 call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1852 $::instance_conf->get_order_warn_no_deliverydate,
1854 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1855 only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type()))
1858 t8('Save and Invoice'),
1859 call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1860 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1863 t8('Save and AP Transaction'),
1864 call => [ 'kivi.Order.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
1865 only_if => (any { $self->type eq $_ } (purchase_order_type()))
1868 ], # end of combobox "Workflow"
1875 t8('Save and print'),
1876 call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts ],
1879 t8('Save and E-mail'),
1880 call => [ 'kivi.Order.email', $::instance_conf->get_order_warn_duplicate_parts ],
1883 t8('Download attachments of all parts'),
1884 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1885 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1886 only_if => $::instance_conf->get_doc_storage,
1888 ], # end of combobox "Export"
1892 call => [ 'kivi.Order.delete_order' ],
1893 confirm => $::locale->text('Do you really want to delete this object?'),
1894 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1895 only_if => $deletion_allowed,
1902 my ($order, $pdf_ref, $params) = @_;
1906 my $print_form = Form->new('');
1907 $print_form->{type} = $order->type;
1908 $print_form->{formname} = $params->{formname} || $order->type;
1909 $print_form->{format} = $params->{format} || 'pdf';
1910 $print_form->{media} = $params->{media} || 'file';
1911 $print_form->{groupitems} = $params->{groupitems};
1912 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1914 $order->language($params->{language});
1915 $order->flatten_to_form($print_form, format_amounts => 1);
1919 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
1920 $template_ext = 'odt';
1921 $template_type = 'OpenDocument';
1924 # search for the template
1925 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1926 name => $print_form->{formname},
1927 extension => $template_ext,
1928 email => $print_form->{media} eq 'email',
1929 language => $params->{language},
1930 printer_id => $print_form->{printer_id}, # todo
1933 if (!defined $template_file) {
1934 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);
1937 return @errors if scalar @errors;
1939 $print_form->throw_on_error(sub {
1941 $print_form->prepare_for_printing;
1943 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1944 format => $print_form->{format},
1945 template_type => $template_type,
1946 template => $template_file,
1947 variables => $print_form,
1948 variable_content_types => {
1949 longdescription => 'html',
1950 partnotes => 'html',
1955 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
1961 sub get_files_for_email_dialog {
1964 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
1966 return %files if !$::instance_conf->get_doc_storage;
1968 if ($self->order->id) {
1969 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
1970 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
1971 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
1975 uniq_by { $_->{id} }
1977 +{ id => $_->part->id,
1978 partnumber => $_->part->partnumber }
1979 } @{$self->order->items_sorted};
1981 foreach my $part (@parts) {
1982 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
1983 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
1986 foreach my $key (keys %files) {
1987 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
1993 sub make_periodic_invoices_config_from_yaml {
1994 my ($yaml_config) = @_;
1996 return if !$yaml_config;
1997 my $attr = SL::YAML::Load($yaml_config);
1998 return if 'HASH' ne ref $attr;
1999 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
2003 sub get_periodic_invoices_status {
2004 my ($self, $config) = @_;
2006 return if $self->type ne sales_order_type();
2007 return t8('not configured') if !$config;
2009 my $active = ('HASH' eq ref $config) ? $config->{active}
2010 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
2011 : die "Cannot get status of periodic invoices config";
2013 return $active ? t8('active') : t8('inactive');
2017 my ($self, $action) = @_;
2019 return '' if none { lc($action)} qw(add edit);
2022 # $::locale->text("Add Sales Order");
2023 # $::locale->text("Add Purchase Order");
2024 # $::locale->text("Add Quotation");
2025 # $::locale->text("Add Request for Quotation");
2026 # $::locale->text("Edit Sales Order");
2027 # $::locale->text("Edit Purchase Order");
2028 # $::locale->text("Edit Quotation");
2029 # $::locale->text("Edit Request for Quotation");
2031 $action = ucfirst(lc($action));
2032 return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order")
2033 : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order")
2034 : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation")
2035 : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
2039 sub get_item_cvpartnumber {
2040 my ($self, $item) = @_;
2042 return if !$self->search_cvpartnumber;
2043 return if !$self->order->customervendor;
2045 if ($self->cv eq 'vendor') {
2046 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
2047 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
2048 } elsif ($self->cv eq 'customer') {
2049 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
2050 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
2054 sub sales_order_type {
2058 sub purchase_order_type {
2062 sub sales_quotation_type {
2066 sub request_quotation_type {
2067 'request_quotation';
2071 return $_[0]->type eq sales_order_type() ? 'ordnumber'
2072 : $_[0]->type eq purchase_order_type() ? 'ordnumber'
2073 : $_[0]->type eq sales_quotation_type() ? 'quonumber'
2074 : $_[0]->type eq request_quotation_type() ? 'quonumber'
2086 SL::Controller::Order - controller for orders
2090 This is a new form to enter orders, completely rewritten with the use
2091 of controller and java script techniques.
2093 The aim is to provide the user a better experience and a faster workflow. Also
2094 the code should be more readable, more reliable and better to maintain.
2102 One input row, so that input happens every time at the same place.
2106 Use of pickers where possible.
2110 Possibility to enter more than one item at once.
2114 Item list in a scrollable area, so that the workflow buttons stay at
2119 Reordering item rows with drag and drop is possible. Sorting item rows is
2120 possible (by partnumber, description, qty, sellprice and discount for now).
2124 No C<update> is necessary. All entries and calculations are managed
2125 with ajax-calls and the page only reloads on C<save>.
2129 User can see changes immediately, because of the use of java script
2140 =item * C<SL/Controller/Order.pm>
2144 =item * C<template/webpages/order/form.html>
2148 =item * C<template/webpages/order/tabs/basic_data.html>
2150 Main tab for basic_data.
2152 This is the only tab here for now. "linked records" and "webdav" tabs are
2153 reused from generic code.
2157 =item * C<template/webpages/order/tabs/_business_info_row.html>
2159 For displaying information on business type
2161 =item * C<template/webpages/order/tabs/_item_input.html>
2163 The input line for items
2165 =item * C<template/webpages/order/tabs/_row.html>
2167 One row for already entered items
2169 =item * C<template/webpages/order/tabs/_tax_row.html>
2171 Displaying tax information
2173 =item * C<template/webpages/order/tabs/_multi_items_dialog.html>
2175 Dialog for entering more than one item at once
2177 =item * C<template/webpages/order/tabs/_multi_items_result.html>
2179 Results for the filter in the multi items dialog
2181 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
2183 Dialog for selecting price and discount sources
2187 =item * C<js/kivi.Order.js>
2189 java script functions
2199 =item * credit limit
2201 =item * more workflows (quotation, rfq)
2203 =item * price sources: little symbols showing better price / better discount
2205 =item * select units in input row?
2207 =item * check for direct delivery (workflow sales order -> purchase order)
2209 =item * language / part translations
2211 =item * access rights
2213 =item * display weights
2219 =item * optional client/user behaviour
2221 (transactions has to be set - department has to be set -
2222 force project if enabled in client config - transport cost reminder)
2226 =head1 KNOWN BUGS AND CAVEATS
2232 Customer discount is not displayed as a valid discount in price source popup
2233 (this might be a bug in price sources)
2235 (I cannot reproduce this (Bernd))
2239 No indication that <shift>-up/down expands/collapses second row.
2243 Inline creation of parts is not currently supported
2247 Table header is not sticky in the scrolling area.
2251 Sorting does not include C<position>, neither does reordering.
2253 This behavior was implemented intentionally. But we can discuss, which behavior
2254 should be implemented.
2258 C<show_multi_items_dialog> does not use the currently inserted string for
2263 =head1 To discuss / Nice to have
2269 How to expand/collapse second row. Now it can be done clicking the icon or
2274 Possibility to change longdescription in input row?
2278 Possibility to select PriceSources in input row?
2282 This controller uses a (changed) copy of the template for the PriceSource
2283 dialog. Maybe there could be used one code source.
2287 Rounding-differences between this controller (PriceTaxCalculator) and the old
2288 form. This is not only a problem here, but also in all parts using the PTC.
2289 There exists a ticket and a patch. This patch should be testet.
2293 An indicator, if the actual inputs are saved (like in an
2294 editor or on text processing application).
2298 A warning when leaving the page without saveing unchanged inputs.
2305 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>