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::PartClassification;
21 use SL::DB::PartsGroup;
24 use SL::DB::RecordLink;
26 use SL::DB::Translation;
28 use SL::Helper::CreatePDF qw(:all);
29 use SL::Helper::PrintOptions;
30 use SL::Helper::ShippedQty;
31 use SL::Helper::UserPreferences::PositionsScrollbar;
32 use SL::Helper::UserPreferences::UpdatePositions;
34 use SL::Controller::Helper::GetModels;
36 use List::Util qw(first sum0);
37 use List::UtilsBy qw(sort_by uniq_by);
38 use List::MoreUtils qw(any none pairwise first_index);
39 use English qw(-no_match_vars);
44 use Rose::Object::MakeMethods::Generic
46 scalar => [ qw(item_ids_to_delete is_custom_shipto_to_delete) ],
47 'scalar --get_set_init' => [ qw(order valid_types type cv p all_price_factors search_cvpartnumber show_update_button part_picker_classification_ids) ],
52 __PACKAGE__->run_before('check_auth');
54 __PACKAGE__->run_before('recalc',
55 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
58 __PACKAGE__->run_before('get_unalterable_data',
59 only => [ qw(save save_as_new save_and_delivery_order save_and_invoice save_and_ap_transaction
70 $self->order->transdate(DateTime->now_local());
71 my $extra_days = $self->{type} eq 'sales_quotation' ? $::instance_conf->get_reqdate_interval :
72 $self->{type} eq 'sales_order' ? $::instance_conf->get_delivery_date_interval : 1;
73 $self->order->reqdate(DateTime->today_local->next_workday(extra_days => $extra_days)) if !$self->order->reqdate;
79 title => $self->get_title_for('add'),
80 %{$self->{template_args}}
84 # edit an existing order
92 # this is to edit an order from an unsaved order object
94 # set item ids to new fake id, to identify them as new items
95 foreach my $item (@{$self->order->items_sorted}) {
96 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
98 # trigger rendering values for second row as hidden, because they
99 # are loaded only on demand. So we need to keep the values from
101 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
108 title => $self->get_title_for('edit'),
109 %{$self->{template_args}}
113 # edit a collective order (consisting of one or more existing orders)
114 sub action_edit_collective {
118 my @multi_ids = map {
119 $_ =~ m{^multi_id_(\d+)$} && $::form->{'multi_id_' . $1} && $::form->{'trans_id_' . $1} && $::form->{'trans_id_' . $1}
120 } grep { $_ =~ m{^multi_id_\d+$} } keys %$::form;
122 # fall back to add if no ids are given
123 if (scalar @multi_ids == 0) {
128 # fall back to save as new if only one id is given
129 if (scalar @multi_ids == 1) {
130 $self->order(SL::DB::Order->new(id => $multi_ids[0])->load);
131 $self->action_save_as_new();
135 # make new order from given orders
136 my @multi_orders = map { SL::DB::Order->new(id => $_)->load } @multi_ids;
137 $self->{converted_from_oe_id} = join ' ', map { $_->id } @multi_orders;
138 $self->order(SL::DB::Order->new_from_multi(\@multi_orders, sort_sources_by => 'transdate'));
140 $self->action_edit();
147 my $errors = $self->delete();
149 if (scalar @{ $errors }) {
150 $self->js->flash('error', $_) foreach @{ $errors };
151 return $self->js->render();
154 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been deleted')
155 : $self->type eq purchase_order_type() ? $::locale->text('The order has been deleted')
156 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been deleted')
157 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been deleted')
159 flash_later('info', $text);
161 my @redirect_params = (
166 $self->redirect_to(@redirect_params);
173 my $errors = $self->save();
175 if (scalar @{ $errors }) {
176 $self->js->flash('error', $_) foreach @{ $errors };
177 return $self->js->render();
180 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
181 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
182 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
183 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
185 flash_later('info', $text);
187 my @redirect_params = (
190 id => $self->order->id,
193 $self->redirect_to(@redirect_params);
196 # save the order as new document an open it for edit
197 sub action_save_as_new {
200 my $order = $self->order;
203 $self->js->flash('error', t8('This object has not been saved yet.'));
204 return $self->js->render();
207 # load order from db to check if values changed
208 my $saved_order = SL::DB::Order->new(id => $order->id)->load;
211 # Lets assign a new number if the user hasn't changed the previous one.
212 # If it has been changed manually then use it as-is.
213 $new_attrs{number} = (trim($order->number) eq $saved_order->number)
215 : trim($order->number);
217 # Clear transdate unless changed
218 $new_attrs{transdate} = ($order->transdate == $saved_order->transdate)
219 ? DateTime->today_local
222 # Set new reqdate unless changed
223 if ($order->reqdate == $saved_order->reqdate) {
224 my $extra_days = $self->{type} eq 'sales_quotation' ? $::instance_conf->get_reqdate_interval :
225 $self->{type} eq 'sales_order' ? $::instance_conf->get_delivery_date_interval : 1;
226 $new_attrs{reqdate} = DateTime->today_local->next_workday(extra_days => $extra_days);
228 $new_attrs{reqdate} = $order->reqdate;
232 $new_attrs{employee} = SL::DB::Manager::Employee->current;
234 # Create new record from current one
235 $self->order(SL::DB::Order->new_from($order, destination_type => $order->type, attributes => \%new_attrs));
237 # no linked records on save as new
238 delete $::form->{$_} for qw(converted_from_oe_id converted_from_orderitems_ids);
241 $self->action_save();
246 # This is called if "print" is pressed in the print dialog.
247 # If PDF creation was requested and succeeded, the pdf is offered for download
248 # via send_file (which uses ajax in this case).
252 my $errors = $self->save();
254 if (scalar @{ $errors }) {
255 $self->js->flash('error', $_) foreach @{ $errors };
256 return $self->js->render();
259 $self->js_reset_order_and_item_ids_after_save;
261 my $format = $::form->{print_options}->{format};
262 my $media = $::form->{print_options}->{media};
263 my $formname = $::form->{print_options}->{formname};
264 my $copies = $::form->{print_options}->{copies};
265 my $groupitems = $::form->{print_options}->{groupitems};
266 my $printer_id = $::form->{print_options}->{printer_id};
268 # only pdf and opendocument by now
269 if (none { $format eq $_ } qw(pdf opendocument opendocument_pdf)) {
270 return $self->js->flash('error', t8('Format \'#1\' is not supported yet/anymore.', $format))->render;
273 # only screen or printer by now
274 if (none { $media eq $_ } qw(screen printer)) {
275 return $self->js->flash('error', t8('Media \'#1\' is not supported yet/anymore.', $media))->render;
278 # create a form for generate_attachment_filename
279 my $form = Form->new;
280 $form->{$self->nr_key()} = $self->order->number;
281 $form->{type} = $self->type;
282 $form->{format} = $format;
283 $form->{formname} = $formname;
284 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
285 my $pdf_filename = $form->generate_attachment_filename();
288 my @errors = generate_pdf($self->order, \$pdf, { format => $format,
289 formname => $formname,
290 language => $self->order->language,
291 printer_id => $printer_id,
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->{cusordnumber} = $self->order->cusordnumber;
380 $form->{formname} = $self->type;
381 $form->{type} = $self->type;
382 $form->{language} = '_' . $self->order->language->template_code if $self->order->language;
383 $form->{language_id} = $self->order->language->id if $self->order->language;
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 my @errors = generate_pdf($self->order, \$pdf, {media => $::form->{media},
436 format => $::form->{print_options}->{format},
437 formname => $::form->{print_options}->{formname},
438 language => $self->order->language,
439 printer_id => $::form->{print_options}->{printer_id},
440 groupitems => $::form->{print_options}->{groupitems}});
441 if (scalar @errors) {
442 return $self->js->flash('error', t8('Conversion to PDF failed: #1', $errors[0]))->render($self);
445 my $sfile = SL::SessionFile::Random->new(mode => "w");
446 $sfile->fh->print($pdf);
449 $::form->{tmpfile} = $sfile->file_name;
450 $::form->{tmpdir} = $sfile->get_path; # for Form::cleanup which may be called in Form::send_email
453 $::form->{id} = $self->order->id; # this is used in SL::Mailer to create a linked record to the mail
454 $::form->send_email(\%::myconfig, 'pdf');
457 my $intnotes = $self->order->intnotes;
458 $intnotes .= "\n\n" if $self->order->intnotes;
459 $intnotes .= t8('[email]') . "\n";
460 $intnotes .= t8('Date') . ": " . $::locale->format_date_object(DateTime->now_local, precision => 'seconds') . "\n";
461 $intnotes .= t8('To (email)') . ": " . $::form->{email} . "\n";
462 $intnotes .= t8('Cc') . ": " . $::form->{cc} . "\n" if $::form->{cc};
463 $intnotes .= t8('Bcc') . ": " . $::form->{bcc} . "\n" if $::form->{bcc};
464 $intnotes .= t8('Subject') . ": " . $::form->{subject} . "\n\n";
465 $intnotes .= t8('Message') . ": " . $::form->{message};
467 $self->order->update_attributes(intnotes => $intnotes);
469 flash_later('info', t8('The email has been sent.'));
471 my @redirect_params = (
474 id => $self->order->id,
477 $self->redirect_to(@redirect_params);
480 # open the periodic invoices config dialog
482 # If there are values in the form (i.e. dialog was opened before),
483 # then use this values. Create new ones, else.
484 sub action_show_periodic_invoices_config_dialog {
487 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
488 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
489 $config ||= SL::DB::PeriodicInvoicesConfig->new(periodicity => 'm',
490 order_value_periodicity => 'p', # = same as periodicity
491 start_date_as_date => $::form->{transdate_as_date} || $::form->current_date,
492 extend_automatically_by => 12,
494 email_subject => GenericTranslations->get(
495 language_id => $::form->{language_id},
496 translation_type =>"preset_text_periodic_invoices_email_subject"),
497 email_body => GenericTranslations->get(
498 language_id => $::form->{language_id},
499 translation_type =>"preset_text_periodic_invoices_email_body"),
501 $config->periodicity('m') if none { $_ eq $config->periodicity } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES;
502 $config->order_value_periodicity('p') if none { $_ eq $config->order_value_periodicity } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES);
504 $::form->get_lists(printers => "ALL_PRINTERS",
505 charts => { key => 'ALL_CHARTS',
506 transdate => 'current_date' });
508 $::form->{AR} = [ grep { $_->{link} =~ m/(?:^|:)AR(?::|$)/ } @{ $::form->{ALL_CHARTS} } ];
510 if ($::form->{customer_id}) {
511 $::form->{ALL_CONTACTS} = SL::DB::Manager::Contact->get_all_sorted(where => [ cp_cv_id => $::form->{customer_id} ]);
512 $::form->{email_recipient_invoice_address} = SL::DB::Manager::Customer->find_by(id => $::form->{customer_id})->invoice_mail;
515 $self->render('oe/edit_periodic_invoices_config', { layout => 0 },
517 popup_js_close_function => 'kivi.Order.close_periodic_invoices_config_dialog()',
518 popup_js_assign_function => 'kivi.Order.assign_periodic_invoices_config()',
523 # assign the values of the periodic invoices config dialog
524 # as yaml in the hidden tag and set the status.
525 sub action_assign_periodic_invoices_config {
528 $::form->isblank('start_date_as_date', $::locale->text('The start date is missing.'));
530 my $config = { active => $::form->{active} ? 1 : 0,
531 terminated => $::form->{terminated} ? 1 : 0,
532 direct_debit => $::form->{direct_debit} ? 1 : 0,
533 periodicity => (any { $_ eq $::form->{periodicity} } @SL::DB::PeriodicInvoicesConfig::PERIODICITIES) ? $::form->{periodicity} : 'm',
534 order_value_periodicity => (any { $_ eq $::form->{order_value_periodicity} } ('p', @SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIODICITIES)) ? $::form->{order_value_periodicity} : 'p',
535 start_date_as_date => $::form->{start_date_as_date},
536 end_date_as_date => $::form->{end_date_as_date},
537 first_billing_date_as_date => $::form->{first_billing_date_as_date},
538 print => $::form->{print} ? 1 : 0,
539 printer_id => $::form->{print} ? $::form->{printer_id} * 1 : undef,
540 copies => $::form->{copies} * 1 ? $::form->{copies} : 1,
541 extend_automatically_by => $::form->{extend_automatically_by} * 1 || undef,
542 ar_chart_id => $::form->{ar_chart_id} * 1,
543 send_email => $::form->{send_email} ? 1 : 0,
544 email_recipient_contact_id => $::form->{email_recipient_contact_id} * 1 || undef,
545 email_recipient_address => $::form->{email_recipient_address},
546 email_sender => $::form->{email_sender},
547 email_subject => $::form->{email_subject},
548 email_body => $::form->{email_body},
551 my $periodic_invoices_config = SL::YAML::Dump($config);
553 my $status = $self->get_periodic_invoices_status($config);
556 ->remove('#order_periodic_invoices_config')
557 ->insertAfter(hidden_tag('order.periodic_invoices_config', $periodic_invoices_config), '#periodic_invoices_status')
558 ->run('kivi.Order.close_periodic_invoices_config_dialog')
559 ->html('#periodic_invoices_status', $status)
560 ->flash('info', t8('The periodic invoices config has been assigned.'))
564 sub action_get_has_active_periodic_invoices {
567 my $config = make_periodic_invoices_config_from_yaml(delete $::form->{config});
568 $config ||= SL::DB::Manager::PeriodicInvoicesConfig->find_by(oe_id => $::form->{id}) if $::form->{id};
570 my $has_active_periodic_invoices =
571 $self->type eq sales_order_type()
574 && (!$config->end_date || ($config->end_date > DateTime->today_local))
575 && $config->get_previous_billed_period_start_date;
577 $_[0]->render(\ !!$has_active_periodic_invoices, { type => 'text' });
580 # save the order and redirect to the frontend subroutine for a new
582 sub action_save_and_delivery_order {
585 $self->save_and_redirect_to(
586 controller => 'oe.pl',
587 action => 'oe_delivery_order_from_order',
591 # save the order and redirect to the frontend subroutine for a new
593 sub action_save_and_invoice {
596 $self->save_and_redirect_to(
597 controller => 'oe.pl',
598 action => 'oe_invoice_from_order',
602 # workflow from sales order to sales quotation
603 sub action_sales_quotation {
604 $_[0]->workflow_sales_or_request_for_quotation();
607 # workflow from sales order to sales quotation
608 sub action_request_for_quotation {
609 $_[0]->workflow_sales_or_request_for_quotation();
612 # workflow from sales quotation to sales order
613 sub action_sales_order {
614 $_[0]->workflow_sales_or_purchase_order();
617 # workflow from rfq to purchase order
618 sub action_purchase_order {
619 $_[0]->workflow_sales_or_purchase_order();
622 # workflow from purchase order to ap transaction
623 sub action_save_and_ap_transaction {
626 $self->save_and_redirect_to(
627 controller => 'ap.pl',
628 action => 'add_from_purchase_order',
632 # set form elements in respect to a changed customer or vendor
634 # This action is called on an change of the customer/vendor picker.
635 sub action_customer_vendor_changed {
638 setup_order_from_cv($self->order);
641 my $cv_method = $self->cv;
643 if ($self->order->$cv_method->contacts && scalar @{ $self->order->$cv_method->contacts } > 0) {
644 $self->js->show('#cp_row');
646 $self->js->hide('#cp_row');
649 if ($self->order->$cv_method->shipto && scalar @{ $self->order->$cv_method->shipto } > 0) {
650 $self->js->show('#shipto_selection');
652 $self->js->hide('#shipto_selection');
655 $self->js->val( '#order_salesman_id', $self->order->salesman_id) if $self->order->is_sales;
658 ->replaceWith('#order_cp_id', $self->build_contact_select)
659 ->replaceWith('#order_shipto_id', $self->build_shipto_select)
660 ->replaceWith('#shipto_inputs ', $self->build_shipto_inputs)
661 ->replaceWith('#business_info_row', $self->build_business_info_row)
662 ->val( '#order_taxzone_id', $self->order->taxzone_id)
663 ->val( '#order_taxincluded', $self->order->taxincluded)
664 ->val( '#order_currency_id', $self->order->currency_id)
665 ->val( '#order_payment_id', $self->order->payment_id)
666 ->val( '#order_delivery_term_id', $self->order->delivery_term_id)
667 ->val( '#order_intnotes', $self->order->intnotes)
668 ->val( '#order_language_id', $self->order->$cv_method->language_id)
669 ->focus( '#order_' . $self->cv . '_id')
670 ->run('kivi.Order.update_exchangerate');
672 $self->js_redisplay_amounts_and_taxes;
673 $self->js_redisplay_cvpartnumbers;
677 # open the dialog for customer/vendor details
678 sub action_show_customer_vendor_details_dialog {
681 my $is_customer = 'customer' eq $::form->{vc};
684 $cv = SL::DB::Customer->new(id => $::form->{vc_id})->load;
686 $cv = SL::DB::Vendor->new(id => $::form->{vc_id})->load;
689 my %details = map { $_ => $cv->$_ } @{$cv->meta->columns};
690 $details{discount_as_percent} = $cv->discount_as_percent;
691 $details{creditlimt} = $cv->creditlimit_as_number;
692 $details{business} = $cv->business->description if $cv->business;
693 $details{language} = $cv->language_obj->description if $cv->language_obj;
694 $details{delivery_terms} = $cv->delivery_term->description if $cv->delivery_term;
695 $details{payment_terms} = $cv->payment->description if $cv->payment;
696 $details{pricegroup} = $cv->pricegroup->pricegroup if $is_customer && $cv->pricegroup;
698 foreach my $entry (@{ $cv->shipto }) {
699 push @{ $details{SHIPTO} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
701 foreach my $entry (@{ $cv->contacts }) {
702 push @{ $details{CONTACTS} }, { map { $_ => $entry->$_ } @{$entry->meta->columns} };
705 $_[0]->render('common/show_vc_details', { layout => 0 },
706 is_customer => $is_customer,
711 # called if a unit in an existing item row is changed
712 sub action_unit_changed {
715 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
716 my $item = $self->order->items_sorted->[$idx];
718 my $old_unit_obj = SL::DB::Unit->new(name => $::form->{old_unit})->load;
719 $item->sellprice($item->unit_obj->convert_to($item->sellprice, $old_unit_obj));
724 ->run('kivi.Order.update_sellprice', $::form->{item_id}, $item->sellprice_as_number);
725 $self->js_redisplay_line_values;
726 $self->js_redisplay_amounts_and_taxes;
730 # add an item row for a new item entered in the input row
731 sub action_add_item {
734 my $form_attr = $::form->{add_item};
736 return unless $form_attr->{parts_id};
738 my $item = new_item($self->order, $form_attr);
740 $self->order->add_items($item);
744 $self->get_item_cvpartnumber($item);
746 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
747 my $row_as_html = $self->p->render('order/tabs/_row',
753 if ($::form->{insert_before_item_id}) {
755 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
758 ->append('#row_table_id', $row_as_html);
761 if ( $item->part->is_assortment ) {
762 $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
763 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
764 my $attr = { parts_id => $assortment_item->parts_id,
765 qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
766 unit => $assortment_item->unit,
767 description => $assortment_item->part->description,
769 my $item = new_item($self->order, $attr);
771 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
772 $item->discount(1) unless $assortment_item->charge;
774 $self->order->add_items( $item );
776 $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',
783 if ($::form->{insert_before_item_id}) {
785 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
788 ->append('#row_table_id', $row_as_html);
794 ->val('.add_item_input', '')
795 ->run('kivi.Order.init_row_handlers')
796 ->run('kivi.Order.renumber_positions')
797 ->focus('#add_item_parts_id_name');
799 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
801 $self->js_redisplay_amounts_and_taxes;
805 # add item rows for multiple items at once
806 sub action_add_multi_items {
809 my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
810 return $self->js->render() unless scalar @form_attr;
813 foreach my $attr (@form_attr) {
814 my $item = new_item($self->order, $attr);
816 if ( $item->part->is_assortment ) {
817 foreach my $assortment_item ( @{$item->part->assortment_items} ) {
818 my $attr = { parts_id => $assortment_item->parts_id,
819 qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
820 unit => $assortment_item->unit,
821 description => $assortment_item->part->description,
823 my $item = new_item($self->order, $attr);
825 # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
826 $item->discount(1) unless $assortment_item->charge;
831 $self->order->add_items(@items);
835 foreach my $item (@items) {
836 $self->get_item_cvpartnumber($item);
837 my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
838 my $row_as_html = $self->p->render('order/tabs/_row',
844 if ($::form->{insert_before_item_id}) {
846 ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
849 ->append('#row_table_id', $row_as_html);
854 ->run('kivi.Part.close_picker_dialogs')
855 ->run('kivi.Order.init_row_handlers')
856 ->run('kivi.Order.renumber_positions')
857 ->focus('#add_item_parts_id_name');
859 $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
861 $self->js_redisplay_amounts_and_taxes;
865 # recalculate all linetotals, amounts and taxes and redisplay them
866 sub action_recalc_amounts_and_taxes {
871 $self->js_redisplay_line_values;
872 $self->js_redisplay_amounts_and_taxes;
876 sub action_update_exchangerate {
880 is_standard => $self->order->currency_id == $::instance_conf->get_currency_id,
881 currency_name => $self->order->currency->name,
882 exchangerate => $self->order->daily_exchangerate_as_null_number,
885 $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
888 # redisplay item rows if they are sorted by an attribute
889 sub action_reorder_items {
893 partnumber => sub { $_[0]->part->partnumber },
894 description => sub { $_[0]->description },
895 qty => sub { $_[0]->qty },
896 sellprice => sub { $_[0]->sellprice },
897 discount => sub { $_[0]->discount },
898 cvpartnumber => sub { $_[0]->{cvpartnumber} },
901 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
903 my $method = $sort_keys{$::form->{order_by}};
904 my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
905 if ($::form->{sort_dir}) {
906 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
907 @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
909 @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
912 if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
913 @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
915 @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
919 ->run('kivi.Order.redisplay_items', \@to_sort)
923 # show the popup to choose a price/discount source
924 sub action_price_popup {
927 my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
928 my $item = $self->order->items_sorted->[$idx];
930 $self->render_price_dialog($item);
933 # load the second row for one or more items
935 # This action gets the html code for all items second rows by rendering a template for
936 # the second row and sets the html code via client js.
937 sub action_load_second_rows {
940 $self->recalc() if $self->order->is_sales; # for margin calculation
942 foreach my $item_id (@{ $::form->{item_ids} }) {
943 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
944 my $item = $self->order->items_sorted->[$idx];
946 $self->js_load_second_row($item, $item_id, 0);
949 $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
954 # update description, notes and sellprice from master data
955 sub action_update_row_from_master_data {
958 foreach my $item_id (@{ $::form->{item_ids} }) {
959 my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
960 my $item = $self->order->items_sorted->[$idx];
961 my $texts = get_part_texts($item->part, $self->order->language_id);
963 $item->description($texts->{description});
964 $item->longdescription($texts->{longdescription});
966 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
969 if ($item->part->is_assortment) {
970 # add assortment items with price 0, as the components carry the price
971 $price_src = $price_source->price_from_source("");
972 $price_src->price(0);
974 $price_src = $price_source->best_price
975 ? $price_source->best_price
976 : $price_source->price_from_source("");
977 $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate;
978 $price_src->price(0) if !$price_source->best_price;
982 $item->sellprice($price_src->price);
983 $item->active_price_source($price_src);
986 ->run('kivi.Order.update_sellprice', $item_id, $item->sellprice_as_number)
987 ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
988 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
989 ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
991 if ($self->search_cvpartnumber) {
992 $self->get_item_cvpartnumber($item);
993 $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
998 $self->js_redisplay_line_values;
999 $self->js_redisplay_amounts_and_taxes;
1001 $self->js->render();
1004 sub js_load_second_row {
1005 my ($self, $item, $item_id, $do_parse) = @_;
1008 # Parse values from form (they are formated while rendering (template)).
1009 # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1010 # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
1011 foreach my $var (@{ $item->cvars_by_config }) {
1012 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1014 $item->parse_custom_variable_values;
1017 my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
1020 ->html('#second_row_' . $item_id, $row_as_html)
1021 ->data('#second_row_' . $item_id, 'loaded', 1);
1024 sub js_redisplay_line_values {
1027 my $is_sales = $self->order->is_sales;
1029 # sales orders with margins
1034 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1035 $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
1036 $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
1037 ]} @{ $self->order->items_sorted };
1041 $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
1042 ]} @{ $self->order->items_sorted };
1046 ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
1049 sub js_redisplay_amounts_and_taxes {
1052 if (scalar @{ $self->{taxes} }) {
1053 $self->js->show('#taxincluded_row_id');
1055 $self->js->hide('#taxincluded_row_id');
1058 if ($self->order->taxincluded) {
1059 $self->js->hide('#subtotal_row_id');
1061 $self->js->show('#subtotal_row_id');
1064 if ($self->order->is_sales) {
1065 my $is_neg = $self->order->marge_total < 0;
1067 ->html('#marge_total_id', $::form->format_amount(\%::myconfig, $self->order->marge_total, 2))
1068 ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
1069 ->action_if( $is_neg, 'addClass', '#marge_total_id', 'plus0')
1070 ->action_if( $is_neg, 'addClass', '#marge_percent_id', 'plus0')
1071 ->action_if( $is_neg, 'addClass', '#marge_percent_sign_id', 'plus0')
1072 ->action_if(!$is_neg, 'removeClass', '#marge_total_id', 'plus0')
1073 ->action_if(!$is_neg, 'removeClass', '#marge_percent_id', 'plus0')
1074 ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
1078 ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
1079 ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
1080 ->remove('.tax_row')
1081 ->insertBefore($self->build_tax_rows, '#amount_row_id');
1084 sub js_redisplay_cvpartnumbers {
1087 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1089 my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
1092 ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
1095 sub js_reset_order_and_item_ids_after_save {
1099 ->val('#id', $self->order->id)
1100 ->val('#converted_from_oe_id', '')
1101 ->val('#order_' . $self->nr_key(), $self->order->number);
1104 foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
1105 next if !$self->order->items_sorted->[$idx]->id;
1106 next if $form_item_id !~ m{^new};
1108 ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
1109 ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
1110 ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
1114 $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
1121 sub init_valid_types {
1122 [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
1128 if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
1129 die "Not a valid type for order";
1132 $self->type($::form->{type});
1138 my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer'
1139 : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
1140 : die "Not a valid type for order";
1145 sub init_search_cvpartnumber {
1148 my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
1149 my $search_cvpartnumber;
1150 $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
1151 $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
1153 return $search_cvpartnumber;
1156 sub init_show_update_button {
1159 !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
1170 sub init_all_price_factors {
1171 SL::DB::Manager::PriceFactor->get_all;
1174 sub init_part_picker_classification_ids {
1176 my $attribute = 'used_for_' . ($self->type =~ m{sales} ? 'sale' : 'purchase');
1178 return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => [ $attribute => 1 ]) } ];
1184 my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
1186 my $right = $right_for->{ $self->type };
1187 $right ||= 'DOES_NOT_EXIST';
1189 $::auth->assert($right);
1192 # build the selection box for contacts
1194 # Needed, if customer/vendor changed.
1195 sub build_contact_select {
1198 select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
1199 value_key => 'cp_id',
1200 title_key => 'full_name_dep',
1201 default => $self->order->cp_id,
1203 style => 'width: 300px',
1207 # build the selection box for shiptos
1209 # Needed, if customer/vendor changed.
1210 sub build_shipto_select {
1213 select_tag('order.shipto_id',
1214 [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
1215 value_key => 'shipto_id',
1216 title_key => 'displayable_id',
1217 default => $self->order->shipto_id,
1219 style => 'width: 300px',
1223 # build the inputs for the cusom shipto dialog
1225 # Needed, if customer/vendor changed.
1226 sub build_shipto_inputs {
1229 my $content = $self->p->render('common/_ship_to_dialog',
1230 vc_obj => $self->order->customervendor,
1231 cs_obj => $self->order->custom_shipto,
1232 cvars => $self->order->custom_shipto->cvars_by_config,
1233 id_selector => '#order_shipto_id');
1235 div_tag($content, id => 'shipto_inputs');
1238 # render the info line for business
1240 # Needed, if customer/vendor changed.
1241 sub build_business_info_row
1243 $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
1246 # build the rows for displaying taxes
1248 # Called if amounts where recalculated and redisplayed.
1249 sub build_tax_rows {
1253 foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
1254 $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
1256 return $rows_as_html;
1260 sub render_price_dialog {
1261 my ($self, $record_item) = @_;
1263 my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
1267 'kivi.io.price_chooser_dialog',
1268 t8('Available Prices'),
1269 $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
1274 # $self->js->text('#dialog_flash_error_content', join ' ', @errors);
1275 # $self->js->show('#dialog_flash_error');
1284 return if !$::form->{id};
1286 $self->order(SL::DB::Order->new(id => $::form->{id})->load);
1288 # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
1289 # You need a custom shipto object to call cvars_by_config to get the cvars.
1290 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
1292 return $self->order;
1295 # load or create a new order object
1297 # And assign changes from the form to this object.
1298 # If the order is loaded from db, check if items are deleted in the form,
1299 # remove them form the object and collect them for removing from db on saving.
1300 # Then create/update items from form (via make_item) and add them.
1304 # add_items adds items to an order with no items for saving, but they cannot
1305 # be retrieved via items until the order is saved. Adding empty items to new
1306 # order here solves this problem.
1308 $order = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
1309 $order ||= SL::DB::Order->new(orderitems => [],
1310 quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())),
1311 currency_id => $::instance_conf->get_currency_id(),);
1313 my $cv_id_method = $self->cv . '_id';
1314 if (!$::form->{id} && $::form->{$cv_id_method}) {
1315 $order->$cv_id_method($::form->{$cv_id_method});
1316 setup_order_from_cv($order);
1319 my $form_orderitems = delete $::form->{order}->{orderitems};
1320 my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
1322 $order->assign_attributes(%{$::form->{order}});
1324 $self->setup_custom_shipto_from_form($order, $::form);
1326 if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) {
1327 my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
1328 $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
1331 # remove deleted items
1332 $self->item_ids_to_delete([]);
1333 foreach my $idx (reverse 0..$#{$order->orderitems}) {
1334 my $item = $order->orderitems->[$idx];
1335 if (none { $item->id == $_->{id} } @{$form_orderitems}) {
1336 splice @{$order->orderitems}, $idx, 1;
1337 push @{$self->item_ids_to_delete}, $item->id;
1343 foreach my $form_attr (@{$form_orderitems}) {
1344 my $item = make_item($order, $form_attr);
1345 $item->position($pos);
1349 $order->add_items(grep {!$_->id} @items);
1354 # create or update items from form
1356 # Make item objects from form values. For items already existing read from db.
1357 # Create a new item else. And assign attributes.
1359 my ($record, $attr) = @_;
1362 $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
1364 my $is_new = !$item;
1366 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1367 # they cannot be retrieved via custom_variables until the order/orderitem is
1368 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1369 $item ||= SL::DB::OrderItem->new(custom_variables => []);
1371 $item->assign_attributes(%$attr);
1374 my $texts = get_part_texts($item->part, $record->language_id);
1375 $item->longdescription($texts->{longdescription}) if !defined $attr->{longdescription};
1376 $item->project_id($record->globalproject_id) if !defined $attr->{project_id};
1377 $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number};
1385 # This is used to add one item
1387 my ($record, $attr) = @_;
1389 my $item = SL::DB::OrderItem->new;
1391 # Remove attributes where the user left or set the inputs empty.
1392 # So these attributes will be undefined and we can distinguish them
1393 # from zero later on.
1394 for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
1395 delete $attr->{$_} if $attr->{$_} eq '';
1398 $item->assign_attributes(%$attr);
1400 my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
1401 my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
1403 $item->unit($part->unit) if !$item->unit;
1406 if ( $part->is_assortment ) {
1407 # add assortment items with price 0, as the components carry the price
1408 $price_src = $price_source->price_from_source("");
1409 $price_src->price(0);
1410 } elsif (defined $item->sellprice) {
1411 $price_src = $price_source->price_from_source("");
1412 $price_src->price($item->sellprice);
1414 $price_src = $price_source->best_price
1415 ? $price_source->best_price
1416 : $price_source->price_from_source("");
1417 $price_src->price($::form->round_amount($price_src->price / $record->exchangerate, 5)) if $record->exchangerate;
1418 $price_src->price(0) if !$price_source->best_price;
1422 if (defined $item->discount) {
1423 $discount_src = $price_source->discount_from_source("");
1424 $discount_src->discount($item->discount);
1426 $discount_src = $price_source->best_discount
1427 ? $price_source->best_discount
1428 : $price_source->discount_from_source("");
1429 $discount_src->discount(0) if !$price_source->best_discount;
1433 $new_attr{part} = $part;
1434 $new_attr{description} = $part->description if ! $item->description;
1435 $new_attr{qty} = 1.0 if ! $item->qty;
1436 $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
1437 $new_attr{sellprice} = $price_src->price;
1438 $new_attr{discount} = $discount_src->discount;
1439 $new_attr{active_price_source} = $price_src;
1440 $new_attr{active_discount_source} = $discount_src;
1441 $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
1442 $new_attr{project_id} = $record->globalproject_id;
1443 $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
1445 # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
1446 # they cannot be retrieved via custom_variables until the order/orderitem is
1447 # saved. Adding empty custom_variables to new orderitem here solves this problem.
1448 $new_attr{custom_variables} = [];
1450 my $texts = get_part_texts($part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription});
1452 $item->assign_attributes(%new_attr, %{ $texts });
1457 sub setup_order_from_cv {
1460 $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id));
1462 $order->intnotes($order->customervendor->notes);
1464 if ($order->is_sales) {
1465 $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
1466 $order->taxincluded(defined($order->customer->taxincluded_checked)
1467 ? $order->customer->taxincluded_checked
1468 : $::myconfig{taxincluded_checked});
1473 # setup custom shipto from form
1475 # The dialog returns form variables starting with 'shipto' and cvars starting
1476 # with 'shiptocvar_'.
1477 # Mark it to be deleted if a shipto from master data is selected
1478 # (i.e. order has a shipto).
1479 # Else, update or create a new custom shipto. If the fields are empty, it
1480 # will not be saved on save.
1481 sub setup_custom_shipto_from_form {
1482 my ($self, $order, $form) = @_;
1484 if ($order->shipto) {
1485 $self->is_custom_shipto_to_delete(1);
1487 my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1489 my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
1490 my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
1492 $custom_shipto->assign_attributes(%$shipto_attrs);
1493 $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
1497 # recalculate prices and taxes
1499 # Using the PriceTaxCalculator. Store linetotals in the item objects.
1503 my %pat = $self->order->calculate_prices_and_taxes();
1505 $self->{taxes} = [];
1506 foreach my $tax_id (keys %{ $pat{taxes_by_tax_id} }) {
1507 my $netamount = sum0 map { $pat{amounts}->{$_}->{amount} } grep { $pat{amounts}->{$_}->{tax_id} == $tax_id } keys %{ $pat{amounts} };
1509 push(@{ $self->{taxes} }, { amount => $pat{taxes_by_tax_id}->{$tax_id},
1510 netamount => $netamount,
1511 tax => SL::DB::Tax->new(id => $tax_id)->load });
1513 pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items_sorted}, @{$pat{items}};
1516 # get data for saving, printing, ..., that is not changed in the form
1518 # Only cvars for now.
1519 sub get_unalterable_data {
1522 foreach my $item (@{ $self->order->items }) {
1523 # autovivify all cvars that are not in the form (cvars_by_config can do it).
1524 # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
1525 foreach my $var (@{ $item->cvars_by_config }) {
1526 $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
1528 $item->parse_custom_variable_values;
1534 # And remove related files in the spool directory
1539 my $db = $self->order->db;
1541 $db->with_transaction(
1543 my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
1544 $self->order->delete;
1545 my $spool = $::lx_office_conf{paths}->{spool};
1546 unlink map { "$spool/$_" } @spoolfiles if $spool;
1549 }) || push(@{$errors}, $db->error);
1556 # And delete items that are deleted in the form.
1561 my $db = $self->order->db;
1563 $db->with_transaction(sub {
1564 # delete custom shipto if it is to be deleted or if it is empty
1565 if ($self->order->custom_shipto && ($self->is_custom_shipto_to_delete || $self->order->custom_shipto->is_empty)) {
1566 $self->order->custom_shipto->delete if $self->order->custom_shipto->shipto_id;
1567 $self->order->custom_shipto(undef);
1570 SL::DB::OrderItem->new(id => $_)->delete for @{$self->item_ids_to_delete || []};
1571 $self->order->save(cascade => 1);
1574 if ($::form->{converted_from_oe_id}) {
1575 my @converted_from_oe_ids = split ' ', $::form->{converted_from_oe_id};
1576 foreach my $converted_from_oe_id (@converted_from_oe_ids) {
1577 my $src = SL::DB::Order->new(id => $converted_from_oe_id)->load;
1578 $src->update_attributes(closed => 1) if $src->type =~ /_quotation$/;
1579 $src->link_to_record($self->order);
1581 if (scalar @{ $::form->{converted_from_orderitems_ids} || [] }) {
1583 foreach (@{ $self->order->items_sorted }) {
1584 my $from_id = $::form->{converted_from_orderitems_ids}->[$idx];
1586 SL::DB::RecordLink->new(from_table => 'orderitems',
1587 from_id => $from_id,
1588 to_table => 'orderitems',
1596 }) || push(@{$errors}, $db->error);
1601 sub workflow_sales_or_request_for_quotation {
1605 my $errors = $self->save();
1607 if (scalar @{ $errors }) {
1608 $self->js->flash('error', $_) for @{ $errors };
1609 return $self->js->render();
1612 my $destination_type = $::form->{type} eq sales_order_type() ? sales_quotation_type() : request_quotation_type();
1614 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1615 $self->{converted_from_oe_id} = delete $::form->{id};
1617 # set item ids to new fake id, to identify them as new items
1618 foreach my $item (@{$self->order->items_sorted}) {
1619 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1623 $::form->{type} = $destination_type;
1624 $self->type($self->init_type);
1625 $self->cv ($self->init_cv);
1629 $self->get_unalterable_data();
1630 $self->pre_render();
1632 # trigger rendering values for second row as hidden, because they
1633 # are loaded only on demand. So we need to keep the values from the
1635 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1639 title => $self->get_title_for('edit'),
1640 %{$self->{template_args}}
1644 sub workflow_sales_or_purchase_order {
1648 my $errors = $self->save();
1650 if (scalar @{ $errors }) {
1651 $self->js->flash('error', $_) foreach @{ $errors };
1652 return $self->js->render();
1655 my $destination_type = $::form->{type} eq sales_quotation_type() ? sales_order_type()
1656 : $::form->{type} eq request_quotation_type() ? purchase_order_type()
1657 : $::form->{type} eq purchase_order_type() ? sales_order_type()
1658 : $::form->{type} eq sales_order_type() ? purchase_order_type()
1661 # check for direct delivery
1662 # copy shipto in custom shipto (custom shipto will be copied by new_from() in case)
1664 if ( $::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()
1665 && $::form->{use_shipto} && $self->order->shipto) {
1666 $custom_shipto = $self->order->shipto->clone('SL::DB::Order');
1669 $self->order(SL::DB::Order->new_from($self->order, destination_type => $destination_type));
1670 $self->{converted_from_oe_id} = delete $::form->{id};
1672 # set item ids to new fake id, to identify them as new items
1673 foreach my $item (@{$self->order->items_sorted}) {
1674 $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
1677 if ($::form->{type} eq sales_order_type() && $destination_type eq purchase_order_type()) {
1678 if ($::form->{use_shipto}) {
1679 $self->order->custom_shipto($custom_shipto) if $custom_shipto;
1681 # remove any custom shipto if not wanted
1682 $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
1687 $::form->{type} = $destination_type;
1688 $self->type($self->init_type);
1689 $self->cv ($self->init_cv);
1693 $self->get_unalterable_data();
1694 $self->pre_render();
1696 # trigger rendering values for second row as hidden, because they
1697 # are loaded only on demand. So we need to keep the values from the
1699 $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
1703 title => $self->get_title_for('edit'),
1704 %{$self->{template_args}}
1712 $self->{all_taxzones} = SL::DB::Manager::TaxZone->get_all_sorted();
1713 $self->{all_currencies} = SL::DB::Manager::Currency->get_all_sorted();
1714 $self->{all_departments} = SL::DB::Manager::Department->get_all_sorted();
1715 $self->{all_languages} = SL::DB::Manager::Language->get_all_sorted();
1716 $self->{all_employees} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->employee_id,
1719 $self->{all_salesmen} = SL::DB::Manager::Employee->get_all(where => [ or => [ id => $self->order->salesman_id,
1722 $self->{all_payment_terms} = SL::DB::Manager::PaymentTerm->get_all_sorted(where => [ or => [ id => $self->order->payment_id,
1724 $self->{all_delivery_terms} = SL::DB::Manager::DeliveryTerm->get_all_sorted();
1725 $self->{current_employee_id} = SL::DB::Manager::Employee->current->id;
1726 $self->{periodic_invoices_status} = $self->get_periodic_invoices_status($self->order->periodic_invoices_config);
1727 $self->{order_probabilities} = [ map { { title => ($_ * 10) . '%', id => $_ * 10 } } (0..10) ];
1728 $self->{positions_scrollbar_height} = SL::Helper::UserPreferences::PositionsScrollbar->new()->get_height();
1730 my $print_form = Form->new('');
1731 $print_form->{type} = $self->type;
1732 $print_form->{printers} = SL::DB::Manager::Printer->get_all_sorted;
1733 $self->{print_options} = SL::Helper::PrintOptions->get_print_options(
1734 form => $print_form,
1735 options => {dialog_name_prefix => 'print_options.',
1739 no_opendocument => 0,
1743 foreach my $item (@{$self->order->orderitems}) {
1744 my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
1745 $item->active_price_source( $price_source->price_from_source( $item->active_price_source ));
1746 $item->active_discount_source($price_source->discount_from_source($item->active_discount_source));
1749 if (any { $self->type eq $_ } (sales_order_type(), purchase_order_type())) {
1750 # calculate shipped qtys here to prevent calling calculate for every item via the items method
1751 SL::Helper::ShippedQty->new->calculate($self->order)->write_to_objects;
1754 if ($self->order->number && $::instance_conf->get_webdav) {
1755 my $webdav = SL::Webdav->new(
1756 type => $self->type,
1757 number => $self->order->number,
1759 my @all_objects = $webdav->get_all_objects;
1760 @{ $self->{template_args}->{WEBDAV} } = map { { name => $_->filename,
1762 link => File::Spec->catfile($_->full_filedescriptor),
1766 $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
1768 $::request->{layout}->use_javascript("${_}.js") for qw(kivi.SalesPurchase kivi.Order kivi.File ckeditor/ckeditor ckeditor/adapters/jquery
1769 edit_periodic_invoices_config calculate_qty kivi.Validator follow_up);
1770 $self->setup_edit_action_bar;
1773 sub setup_edit_action_bar {
1774 my ($self, %params) = @_;
1776 my $deletion_allowed = (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type()))
1777 || (($self->type eq sales_order_type()) && $::instance_conf->get_sales_order_show_delete)
1778 || (($self->type eq purchase_order_type()) && $::instance_conf->get_purchase_order_show_delete);
1780 for my $bar ($::request->layout->get('actionbar')) {
1785 call => [ 'kivi.Order.save', 'save', $::instance_conf->get_order_warn_duplicate_parts,
1786 $::instance_conf->get_order_warn_no_deliverydate,
1788 checks => [ 'kivi.Order.check_save_active_periodic_invoices', ['kivi.validate_form','#order_form'] ],
1792 call => [ 'kivi.Order.save', 'save_as_new', $::instance_conf->get_order_warn_duplicate_parts ],
1793 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1794 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1796 ], # end of combobox "Save"
1803 t8('Save and Quotation'),
1804 submit => [ '#order_form', { action => "Order/sales_quotation" } ],
1805 only_if => (any { $self->type eq $_ } (sales_order_type())),
1809 submit => [ '#order_form', { action => "Order/request_for_quotation" } ],
1810 only_if => (any { $self->type eq $_ } (purchase_order_type())),
1813 t8('Save and Sales Order'),
1814 submit => [ '#order_form', { action => "Order/sales_order" } ],
1815 only_if => (any { $self->type eq $_ } (sales_quotation_type(), purchase_order_type())),
1818 t8('Save and Purchase Order'),
1819 call => [ 'kivi.Order.purchase_order_check_for_direct_delivery' ],
1820 only_if => (any { $self->type eq $_ } (sales_order_type(), request_quotation_type())),
1823 t8('Save and Delivery Order'),
1824 call => [ 'kivi.Order.save', 'save_and_delivery_order', $::instance_conf->get_order_warn_duplicate_parts,
1825 $::instance_conf->get_order_warn_no_deliverydate,
1827 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1828 only_if => (any { $self->type eq $_ } (sales_order_type(), purchase_order_type()))
1831 t8('Save and Invoice'),
1832 call => [ 'kivi.Order.save', 'save_and_invoice', $::instance_conf->get_order_warn_duplicate_parts ],
1833 checks => [ 'kivi.Order.check_save_active_periodic_invoices' ],
1836 t8('Save and AP Transaction'),
1837 call => [ 'kivi.Order.save', 'save_and_ap_transaction', $::instance_conf->get_order_warn_duplicate_parts ],
1838 only_if => (any { $self->type eq $_ } (purchase_order_type()))
1841 ], # end of combobox "Workflow"
1848 t8('Save and print'),
1849 call => [ 'kivi.Order.show_print_options', $::instance_conf->get_order_warn_duplicate_parts ],
1852 t8('Save and E-mail'),
1853 call => [ 'kivi.Order.save', 'save_and_show_email_dialog', $::instance_conf->get_order_warn_duplicate_parts ],
1854 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1857 t8('Download attachments of all parts'),
1858 call => [ 'kivi.File.downloadOrderitemsFiles', $::form->{type}, $::form->{id} ],
1859 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1860 only_if => $::instance_conf->get_doc_storage,
1862 ], # end of combobox "Export"
1866 call => [ 'kivi.Order.delete_order' ],
1867 confirm => $::locale->text('Do you really want to delete this object?'),
1868 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1869 only_if => $deletion_allowed,
1878 call => [ 'kivi.Order.follow_up_window' ],
1879 disabled => !$self->order->id ? t8('This object has not been saved yet.') : undef,
1880 only_if => $::auth->assert('productivity', 1),
1882 ], # end of combobox "more"
1888 my ($order, $pdf_ref, $params) = @_;
1892 my $print_form = Form->new('');
1893 $print_form->{type} = $order->type;
1894 $print_form->{formname} = $params->{formname} || $order->type;
1895 $print_form->{format} = $params->{format} || 'pdf';
1896 $print_form->{media} = $params->{media} || 'file';
1897 $print_form->{groupitems} = $params->{groupitems};
1898 $print_form->{printer_id} = $params->{printer_id};
1899 $print_form->{media} = 'file' if $print_form->{media} eq 'screen';
1901 $order->language($params->{language});
1902 $order->flatten_to_form($print_form, format_amounts => 1);
1906 if ($print_form->{format} =~ /(opendocument|oasis)/i) {
1907 $template_ext = 'odt';
1908 $template_type = 'OpenDocument';
1911 # search for the template
1912 my ($template_file, @template_files) = SL::Helper::CreatePDF->find_template(
1913 name => $print_form->{formname},
1914 extension => $template_ext,
1915 email => $print_form->{media} eq 'email',
1916 language => $params->{language},
1917 printer_id => $print_form->{printer_id},
1920 if (!defined $template_file) {
1921 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);
1924 return @errors if scalar @errors;
1926 $print_form->throw_on_error(sub {
1928 $print_form->prepare_for_printing;
1930 $$pdf_ref = SL::Helper::CreatePDF->create_pdf(
1931 format => $print_form->{format},
1932 template_type => $template_type,
1933 template => $template_file,
1934 variables => $print_form,
1935 variable_content_types => {
1936 longdescription => 'html',
1937 partnotes => 'html',
1942 } || push @errors, ref($EVAL_ERROR) eq 'SL::X::FormError' ? $EVAL_ERROR->error : $EVAL_ERROR;
1948 sub get_files_for_email_dialog {
1951 my %files = map { ($_ => []) } qw(versions files vc_files part_files);
1953 return %files if !$::instance_conf->get_doc_storage;
1955 if ($self->order->id) {
1956 $files{versions} = [ SL::File->get_all_versions(object_id => $self->order->id, object_type => $self->order->type, file_type => 'document') ];
1957 $files{files} = [ SL::File->get_all( object_id => $self->order->id, object_type => $self->order->type, file_type => 'attachment') ];
1958 $files{vc_files} = [ SL::File->get_all( object_id => $self->order->{$self->cv}->id, object_type => $self->cv, file_type => 'attachment') ];
1962 uniq_by { $_->{id} }
1964 +{ id => $_->part->id,
1965 partnumber => $_->part->partnumber }
1966 } @{$self->order->items_sorted};
1968 foreach my $part (@parts) {
1969 my @pfiles = SL::File->get_all(object_id => $part->{id}, object_type => 'part');
1970 push @{ $files{part_files} }, map { +{ %{ $_ }, partnumber => $part->{partnumber} } } @pfiles;
1973 foreach my $key (keys %files) {
1974 $files{$key} = [ sort_by { lc $_->{db_file}->{file_name} } @{ $files{$key} } ];
1980 sub make_periodic_invoices_config_from_yaml {
1981 my ($yaml_config) = @_;
1983 return if !$yaml_config;
1984 my $attr = SL::YAML::Load($yaml_config);
1985 return if 'HASH' ne ref $attr;
1986 return SL::DB::PeriodicInvoicesConfig->new(%$attr);
1990 sub get_periodic_invoices_status {
1991 my ($self, $config) = @_;
1993 return if $self->type ne sales_order_type();
1994 return t8('not configured') if !$config;
1996 my $active = ('HASH' eq ref $config) ? $config->{active}
1997 : ('SL::DB::PeriodicInvoicesConfig' eq ref $config) ? $config->active
1998 : die "Cannot get status of periodic invoices config";
2000 return $active ? t8('active') : t8('inactive');
2004 my ($self, $action) = @_;
2006 return '' if none { lc($action)} qw(add edit);
2009 # $::locale->text("Add Sales Order");
2010 # $::locale->text("Add Purchase Order");
2011 # $::locale->text("Add Quotation");
2012 # $::locale->text("Add Request for Quotation");
2013 # $::locale->text("Edit Sales Order");
2014 # $::locale->text("Edit Purchase Order");
2015 # $::locale->text("Edit Quotation");
2016 # $::locale->text("Edit Request for Quotation");
2018 $action = ucfirst(lc($action));
2019 return $self->type eq sales_order_type() ? $::locale->text("$action Sales Order")
2020 : $self->type eq purchase_order_type() ? $::locale->text("$action Purchase Order")
2021 : $self->type eq sales_quotation_type() ? $::locale->text("$action Quotation")
2022 : $self->type eq request_quotation_type() ? $::locale->text("$action Request for Quotation")
2026 sub get_item_cvpartnumber {
2027 my ($self, $item) = @_;
2029 return if !$self->search_cvpartnumber;
2030 return if !$self->order->customervendor;
2032 if ($self->cv eq 'vendor') {
2033 my @mms = grep { $_->make eq $self->order->customervendor->id } @{$item->part->makemodels};
2034 $item->{cvpartnumber} = $mms[0]->model if scalar @mms;
2035 } elsif ($self->cv eq 'customer') {
2036 my @cps = grep { $_->customer_id eq $self->order->customervendor->id } @{$item->part->customerprices};
2037 $item->{cvpartnumber} = $cps[0]->customer_partnumber if scalar @cps;
2041 sub get_part_texts {
2042 my ($part_or_id, $language_or_id, %defaults) = @_;
2044 my $part = ref($part_or_id) ? $part_or_id : SL::DB::Part->load_cached($part_or_id);
2045 my $language_id = ref($language_or_id) ? $language_or_id->id : $language_or_id;
2047 description => $defaults{description} // $part->description,
2048 longdescription => $defaults{longdescription} // $part->notes,
2051 return $texts unless $language_id;
2053 my $translation = SL::DB::Manager::Translation->get_first(
2055 parts_id => $part->id,
2056 language_id => $language_id,
2059 $texts->{description} = $translation->translation if $translation && $translation->translation;
2060 $texts->{longdescription} = $translation->longdescription if $translation && $translation->longdescription;
2065 sub sales_order_type {
2069 sub purchase_order_type {
2073 sub sales_quotation_type {
2077 sub request_quotation_type {
2078 'request_quotation';
2082 return $_[0]->type eq sales_order_type() ? 'ordnumber'
2083 : $_[0]->type eq purchase_order_type() ? 'ordnumber'
2084 : $_[0]->type eq sales_quotation_type() ? 'quonumber'
2085 : $_[0]->type eq request_quotation_type() ? 'quonumber'
2089 sub save_and_redirect_to {
2090 my ($self, %params) = @_;
2092 my $errors = $self->save();
2094 if (scalar @{ $errors }) {
2095 $self->js->flash('error', $_) foreach @{ $errors };
2096 return $self->js->render();
2099 my $text = $self->type eq sales_order_type() ? $::locale->text('The order has been saved')
2100 : $self->type eq purchase_order_type() ? $::locale->text('The order has been saved')
2101 : $self->type eq sales_quotation_type() ? $::locale->text('The quotation has been saved')
2102 : $self->type eq request_quotation_type() ? $::locale->text('The rfq has been saved')
2104 flash_later('info', $text);
2106 $self->redirect_to(%params, id => $self->order->id);
2117 SL::Controller::Order - controller for orders
2121 This is a new form to enter orders, completely rewritten with the use
2122 of controller and java script techniques.
2124 The aim is to provide the user a better experience and a faster workflow. Also
2125 the code should be more readable, more reliable and better to maintain.
2133 One input row, so that input happens every time at the same place.
2137 Use of pickers where possible.
2141 Possibility to enter more than one item at once.
2145 Item list in a scrollable area, so that the workflow buttons stay at
2150 Reordering item rows with drag and drop is possible. Sorting item rows is
2151 possible (by partnumber, description, qty, sellprice and discount for now).
2155 No C<update> is necessary. All entries and calculations are managed
2156 with ajax-calls and the page only reloads on C<save>.
2160 User can see changes immediately, because of the use of java script
2171 =item * C<SL/Controller/Order.pm>
2175 =item * C<template/webpages/order/form.html>
2179 =item * C<template/webpages/order/tabs/basic_data.html>
2181 Main tab for basic_data.
2183 This is the only tab here for now. "linked records" and "webdav" tabs are
2184 reused from generic code.
2188 =item * C<template/webpages/order/tabs/_business_info_row.html>
2190 For displaying information on business type
2192 =item * C<template/webpages/order/tabs/_item_input.html>
2194 The input line for items
2196 =item * C<template/webpages/order/tabs/_row.html>
2198 One row for already entered items
2200 =item * C<template/webpages/order/tabs/_tax_row.html>
2202 Displaying tax information
2204 =item * C<template/webpages/order/tabs/_price_sources_dialog.html>
2206 Dialog for selecting price and discount sources
2210 =item * C<js/kivi.Order.js>
2212 java script functions
2222 =item * price sources: little symbols showing better price / better discount
2224 =item * select units in input row?
2226 =item * check for direct delivery (workflow sales order -> purchase order)
2228 =item * access rights
2230 =item * display weights
2236 =item * optional client/user behaviour
2238 (transactions has to be set - department has to be set -
2239 force project if enabled in client config - transport cost reminder)
2243 =head1 KNOWN BUGS AND CAVEATS
2249 Customer discount is not displayed as a valid discount in price source popup
2250 (this might be a bug in price sources)
2252 (I cannot reproduce this (Bernd))
2256 No indication that <shift>-up/down expands/collapses second row.
2260 Inline creation of parts is not currently supported
2264 Table header is not sticky in the scrolling area.
2268 Sorting does not include C<position>, neither does reordering.
2270 This behavior was implemented intentionally. But we can discuss, which behavior
2271 should be implemented.
2275 =head1 To discuss / Nice to have
2281 How to expand/collapse second row. Now it can be done clicking the icon or
2286 Possibility to select PriceSources in input row?
2290 This controller uses a (changed) copy of the template for the PriceSource
2291 dialog. Maybe there could be used one code source.
2295 Rounding-differences between this controller (PriceTaxCalculator) and the old
2296 form. This is not only a problem here, but also in all parts using the PTC.
2297 There exists a ticket and a patch. This patch should be testet.
2301 An indicator, if the actual inputs are saved (like in an
2302 editor or on text processing application).
2306 A warning when leaving the page without saveing unchanged inputs.
2313 Bernd Bleßmann E<lt>bernd@kivitendo-premium.deE<gt>