+# add an item row for a new item entered in the input row
+sub action_add_item {
+ my ($self) = @_;
+
+ delete $::form->{add_item}->{create_part_type};
+
+ my $form_attr = $::form->{add_item};
+
+ return unless $form_attr->{parts_id};
+
+ my $item = new_item($self->order, $form_attr);
+
+ $self->order->add_items($item);
+
+ $self->recalc();
+
+ $self->get_item_cvpartnumber($item);
+
+ my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
+ my $row_as_html = $self->p->render('order/tabs/_row',
+ ITEM => $item,
+ ID => $item_id,
+ SELF => $self,
+ );
+
+ if ($::form->{insert_before_item_id}) {
+ $self->js
+ ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
+ } else {
+ $self->js
+ ->append('#row_table_id', $row_as_html);
+ }
+
+ if ( $item->part->is_assortment ) {
+ $form_attr->{qty_as_number} = 1 unless $form_attr->{qty_as_number};
+ foreach my $assortment_item ( @{$item->part->assortment_items} ) {
+ my $attr = { parts_id => $assortment_item->parts_id,
+ qty => $assortment_item->qty * $::form->parse_amount(\%::myconfig, $form_attr->{qty_as_number}), # TODO $form_attr->{unit}
+ unit => $assortment_item->unit,
+ description => $assortment_item->part->description,
+ };
+ my $item = new_item($self->order, $attr);
+
+ # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
+ $item->discount(1) unless $assortment_item->charge;
+
+ $self->order->add_items( $item );
+ $self->recalc();
+ $self->get_item_cvpartnumber($item);
+ my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
+ my $row_as_html = $self->p->render('order/tabs/_row',
+ ITEM => $item,
+ ID => $item_id,
+ SELF => $self,
+ );
+ if ($::form->{insert_before_item_id}) {
+ $self->js
+ ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
+ } else {
+ $self->js
+ ->append('#row_table_id', $row_as_html);
+ }
+ };
+ };
+
+ $self->js
+ ->val('.add_item_input', '')
+ ->run('kivi.Order.init_row_handlers')
+ ->run('kivi.Order.renumber_positions')
+ ->focus('#add_item_parts_id_name');
+
+ $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
+
+ $self->js_redisplay_amounts_and_taxes;
+ $self->js->render();
+}
+
+# add item rows for multiple items at once
+sub action_add_multi_items {
+ my ($self) = @_;
+
+ my @form_attr = grep { $_->{qty_as_number} } @{ $::form->{add_items} };
+ return $self->js->render() unless scalar @form_attr;
+
+ my @items;
+ foreach my $attr (@form_attr) {
+ my $item = new_item($self->order, $attr);
+ push @items, $item;
+ if ( $item->part->is_assortment ) {
+ foreach my $assortment_item ( @{$item->part->assortment_items} ) {
+ my $attr = { parts_id => $assortment_item->parts_id,
+ qty => $assortment_item->qty * $item->qty, # TODO $form_attr->{unit}
+ unit => $assortment_item->unit,
+ description => $assortment_item->part->description,
+ };
+ my $item = new_item($self->order, $attr);
+
+ # set discount to 100% if item isn't supposed to be charged, overwriting any customer discount
+ $item->discount(1) unless $assortment_item->charge;
+ push @items, $item;
+ }
+ }
+ }
+ $self->order->add_items(@items);
+
+ $self->recalc();
+
+ foreach my $item (@items) {
+ $self->get_item_cvpartnumber($item);
+ my $item_id = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
+ my $row_as_html = $self->p->render('order/tabs/_row',
+ ITEM => $item,
+ ID => $item_id,
+ SELF => $self,
+ );
+
+ if ($::form->{insert_before_item_id}) {
+ $self->js
+ ->before ('.row_entry:has(#item_' . $::form->{insert_before_item_id} . ')', $row_as_html);
+ } else {
+ $self->js
+ ->append('#row_table_id', $row_as_html);
+ }
+ }
+
+ $self->js
+ ->run('kivi.Part.close_picker_dialogs')
+ ->run('kivi.Order.init_row_handlers')
+ ->run('kivi.Order.renumber_positions')
+ ->focus('#add_item_parts_id_name');
+
+ $self->js->run('kivi.Order.row_table_scroll_down') if !$::form->{insert_before_item_id};
+
+ $self->js_redisplay_amounts_and_taxes;
+ $self->js->render();
+}
+
+# recalculate all linetotals, amounts and taxes and redisplay them
+sub action_recalc_amounts_and_taxes {
+ my ($self) = @_;
+
+ $self->recalc();
+
+ $self->js_redisplay_line_values;
+ $self->js_redisplay_amounts_and_taxes;
+ $self->js->render();
+}
+
+sub action_update_exchangerate {
+ my ($self) = @_;
+
+ my $data = {
+ is_standard => $self->order->currency_id == $::instance_conf->get_currency_id,
+ currency_name => $self->order->currency->name,
+ exchangerate => $self->order->daily_exchangerate_as_null_number,
+ };
+
+ $self->render(\SL::JSON::to_json($data), { type => 'json', process => 0 });
+}
+
+# redisplay item rows if they are sorted by an attribute
+sub action_reorder_items {
+ my ($self) = @_;
+
+ my %sort_keys = (
+ partnumber => sub { $_[0]->part->partnumber },
+ description => sub { $_[0]->description },
+ qty => sub { $_[0]->qty },
+ sellprice => sub { $_[0]->sellprice },
+ discount => sub { $_[0]->discount },
+ cvpartnumber => sub { $_[0]->{cvpartnumber} },
+ );
+
+ $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
+
+ my $method = $sort_keys{$::form->{order_by}};
+ my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @{ $self->order->items_sorted };
+ if ($::form->{sort_dir}) {
+ if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
+ @to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
+ } else {
+ @to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
+ }
+ } else {
+ if ( $::form->{order_by} =~ m/qty|sellprice|discount/ ){
+ @to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
+ } else {
+ @to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
+ }
+ }
+ $self->js
+ ->run('kivi.Order.redisplay_items', \@to_sort)
+ ->render;
+}
+
+# show the popup to choose a price/discount source
+sub action_price_popup {
+ my ($self) = @_;
+
+ my $idx = first_index { $_ eq $::form->{item_id} } @{ $::form->{orderitem_ids} };
+ my $item = $self->order->items_sorted->[$idx];
+
+ $self->render_price_dialog($item);
+}
+
+# save the order in a session variable and redirect to the part controller
+sub action_create_part {
+ my ($self) = @_;
+
+ my $previousform = $::auth->save_form_in_session(non_scalars => 1);
+
+ my $callback = $self->url_for(
+ action => 'return_from_create_part',
+ type => $self->type, # type is needed for check_auth on return
+ previousform => $previousform,
+ );
+
+ flash_later('info', t8('You are adding a new part while you are editing another document. You will be redirected to your document when saving the new part or aborting this form.'));
+
+ my @redirect_params = (
+ controller => 'Part',
+ action => 'add',
+ part_type => $::form->{add_item}->{create_part_type},
+ callback => $callback,
+ show_abort => 1,
+ );
+
+ $self->redirect_to(@redirect_params);
+}
+
+sub action_return_from_create_part {
+ my ($self) = @_;
+
+ $self->{created_part} = SL::DB::Part->new(id => delete $::form->{new_parts_id})->load if $::form->{new_parts_id};
+
+ $::auth->restore_form_from_session(delete $::form->{previousform});
+
+ # set item ids to new fake id, to identify them as new items
+ foreach my $item (@{$self->order->items_sorted}) {
+ $item->{new_fake_id} = join('_', 'new', Time::HiRes::gettimeofday(), int rand 1000000000000);
+ }
+
+ $self->recalc();
+ $self->get_unalterable_data();
+ $self->pre_render();
+
+ # trigger rendering values for second row/longdescription as hidden,
+ # because they are loaded only on demand. So we need to keep the values
+ # from the source.
+ $_->{render_second_row} = 1 for @{ $self->order->items_sorted };
+ $_->{render_longdescription} = 1 for @{ $self->order->items_sorted };
+
+ $self->render(
+ 'order/form',
+ title => $self->get_title_for('edit'),
+ %{$self->{template_args}}
+ );
+
+}
+
+# load the second row for one or more items
+#
+# This action gets the html code for all items second rows by rendering a template for
+# the second row and sets the html code via client js.
+sub action_load_second_rows {
+ my ($self) = @_;
+
+ $self->recalc() if $self->order->is_sales; # for margin calculation
+
+ foreach my $item_id (@{ $::form->{item_ids} }) {
+ my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
+ my $item = $self->order->items_sorted->[$idx];
+
+ $self->js_load_second_row($item, $item_id, 0);
+ }
+
+ $self->js->run('kivi.Order.init_row_handlers') if $self->order->is_sales; # for lastcosts change-callback
+
+ $self->js->render();
+}
+
+# update description, notes and sellprice from master data
+sub action_update_row_from_master_data {
+ my ($self) = @_;
+
+ foreach my $item_id (@{ $::form->{item_ids} }) {
+ my $idx = first_index { $_ eq $item_id } @{ $::form->{orderitem_ids} };
+ my $item = $self->order->items_sorted->[$idx];
+ my $texts = get_part_texts($item->part, $self->order->language_id);
+
+ $item->description($texts->{description});
+ $item->longdescription($texts->{longdescription});
+
+ my $price_source = SL::PriceSource->new(record_item => $item, record => $self->order);
+
+ my $price_src;
+ if ($item->part->is_assortment) {
+ # add assortment items with price 0, as the components carry the price
+ $price_src = $price_source->price_from_source("");
+ $price_src->price(0);
+ } else {
+ $price_src = $price_source->best_price
+ ? $price_source->best_price
+ : $price_source->price_from_source("");
+ $price_src->price($::form->round_amount($price_src->price / $self->order->exchangerate, 5)) if $self->order->exchangerate;
+ $price_src->price(0) if !$price_source->best_price;
+ }
+
+
+ $item->sellprice($price_src->price);
+ $item->active_price_source($price_src);
+
+ $self->js
+ ->run('kivi.Order.update_sellprice', $item_id, $item->sellprice_as_number)
+ ->html('.row_entry:has(#item_' . $item_id . ') [name = "partnumber"] a', $item->part->partnumber)
+ ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].description"]', $item->description)
+ ->val ('.row_entry:has(#item_' . $item_id . ') [name = "order.orderitems[].longdescription"]', $item->longdescription);
+
+ if ($self->search_cvpartnumber) {
+ $self->get_item_cvpartnumber($item);
+ $self->js->html('.row_entry:has(#item_' . $item_id . ') [name = "cvpartnumber"]', $item->{cvpartnumber});
+ }
+ }
+
+ $self->recalc();
+ $self->js_redisplay_line_values;
+ $self->js_redisplay_amounts_and_taxes;
+
+ $self->js->render();
+}
+
+sub js_load_second_row {
+ my ($self, $item, $item_id, $do_parse) = @_;
+
+ if ($do_parse) {
+ # Parse values from form (they are formated while rendering (template)).
+ # Workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
+ # This parsing is not necessary at all, if we assure that the second row/cvars are only loaded once.
+ foreach my $var (@{ $item->cvars_by_config }) {
+ $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
+ }
+ $item->parse_custom_variable_values;
+ }
+
+ my $row_as_html = $self->p->render('order/tabs/_second_row', ITEM => $item, TYPE => $self->type);
+
+ $self->js
+ ->html('#second_row_' . $item_id, $row_as_html)
+ ->data('#second_row_' . $item_id, 'loaded', 1);
+}
+
+sub js_redisplay_line_values {
+ my ($self) = @_;
+
+ my $is_sales = $self->order->is_sales;
+
+ # sales orders with margins
+ my @data;
+ if ($is_sales) {
+ @data = map {
+ [
+ $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
+ $::form->format_amount(\%::myconfig, $_->{marge_total}, 2, 0),
+ $::form->format_amount(\%::myconfig, $_->{marge_percent}, 2, 0),
+ ]} @{ $self->order->items_sorted };
+ } else {
+ @data = map {
+ [
+ $::form->format_amount(\%::myconfig, $_->{linetotal}, 2, 0),
+ ]} @{ $self->order->items_sorted };
+ }
+
+ $self->js
+ ->run('kivi.Order.redisplay_line_values', $is_sales, \@data);
+}
+
+sub js_redisplay_amounts_and_taxes {
+ my ($self) = @_;
+
+ if (scalar @{ $self->{taxes} }) {
+ $self->js->show('#taxincluded_row_id');
+ } else {
+ $self->js->hide('#taxincluded_row_id');
+ }
+
+ if ($self->order->taxincluded) {
+ $self->js->hide('#subtotal_row_id');
+ } else {
+ $self->js->show('#subtotal_row_id');
+ }
+
+ if ($self->order->is_sales) {
+ my $is_neg = $self->order->marge_total < 0;
+ $self->js
+ ->html('#marge_total_id', $::form->format_amount(\%::myconfig, $self->order->marge_total, 2))
+ ->html('#marge_percent_id', $::form->format_amount(\%::myconfig, $self->order->marge_percent, 2))
+ ->action_if( $is_neg, 'addClass', '#marge_total_id', 'plus0')
+ ->action_if( $is_neg, 'addClass', '#marge_percent_id', 'plus0')
+ ->action_if( $is_neg, 'addClass', '#marge_percent_sign_id', 'plus0')
+ ->action_if(!$is_neg, 'removeClass', '#marge_total_id', 'plus0')
+ ->action_if(!$is_neg, 'removeClass', '#marge_percent_id', 'plus0')
+ ->action_if(!$is_neg, 'removeClass', '#marge_percent_sign_id', 'plus0');
+ }
+
+ $self->js
+ ->html('#netamount_id', $::form->format_amount(\%::myconfig, $self->order->netamount, -2))
+ ->html('#amount_id', $::form->format_amount(\%::myconfig, $self->order->amount, -2))
+ ->remove('.tax_row')
+ ->insertBefore($self->build_tax_rows, '#amount_row_id');
+}
+
+sub js_redisplay_cvpartnumbers {
+ my ($self) = @_;
+
+ $self->get_item_cvpartnumber($_) for @{$self->order->items_sorted};
+
+ my @data = map {[$_->{cvpartnumber}]} @{ $self->order->items_sorted };
+
+ $self->js
+ ->run('kivi.Order.redisplay_cvpartnumbers', \@data);
+}
+
+sub js_reset_order_and_item_ids_after_save {
+ my ($self) = @_;
+
+ $self->js
+ ->val('#id', $self->order->id)
+ ->val('#converted_from_oe_id', '')
+ ->val('#order_' . $self->nr_key(), $self->order->number);
+
+ my $idx = 0;
+ foreach my $form_item_id (@{ $::form->{orderitem_ids} }) {
+ next if !$self->order->items_sorted->[$idx]->id;
+ next if $form_item_id !~ m{^new};
+ $self->js
+ ->val ('[name="orderitem_ids[+]"][value="' . $form_item_id . '"]', $self->order->items_sorted->[$idx]->id)
+ ->val ('#item_' . $form_item_id, $self->order->items_sorted->[$idx]->id)
+ ->attr('#item_' . $form_item_id, "id", 'item_' . $self->order->items_sorted->[$idx]->id);
+ } continue {
+ $idx++;
+ }
+ $self->js->val('[name="converted_from_orderitems_ids[+]"]', '');
+}
+
+#
+# helpers
+#
+
+sub init_valid_types {
+ [ sales_order_type(), purchase_order_type(), sales_quotation_type(), request_quotation_type() ];
+}
+
+sub init_type {
+ my ($self) = @_;
+
+ if (none { $::form->{type} eq $_ } @{$self->valid_types}) {
+ die "Not a valid type for order";
+ }
+
+ $self->type($::form->{type});
+}
+
+sub init_cv {
+ my ($self) = @_;
+
+ my $cv = (any { $self->type eq $_ } (sales_order_type(), sales_quotation_type())) ? 'customer'
+ : (any { $self->type eq $_ } (purchase_order_type(), request_quotation_type())) ? 'vendor'
+ : die "Not a valid type for order";
+
+ return $cv;
+}
+
+sub init_search_cvpartnumber {
+ my ($self) = @_;
+
+ my $user_prefs = SL::Helper::UserPreferences::PartPickerSearch->new();
+ my $search_cvpartnumber;
+ $search_cvpartnumber = !!$user_prefs->get_sales_search_customer_partnumber() if $self->cv eq 'customer';
+ $search_cvpartnumber = !!$user_prefs->get_purchase_search_makemodel() if $self->cv eq 'vendor';
+
+ return $search_cvpartnumber;
+}
+
+sub init_show_update_button {
+ my ($self) = @_;
+
+ !!SL::Helper::UserPreferences::UpdatePositions->new()->get_show_update_button();
+}
+
+sub init_p {
+ SL::Presenter->get;
+}
+
+sub init_order {
+ $_[0]->make_order;
+}
+
+sub init_all_price_factors {
+ SL::DB::Manager::PriceFactor->get_all;
+}
+
+sub init_part_picker_classification_ids {
+ my ($self) = @_;
+ my $attribute = 'used_for_' . ($self->type =~ m{sales} ? 'sale' : 'purchase');
+
+ return [ map { $_->id } @{ SL::DB::Manager::PartClassification->get_all(where => [ $attribute => 1 ]) } ];
+}
+
+sub check_auth {
+ my ($self) = @_;
+
+ my $right_for = { map { $_ => $_.'_edit' . ' | ' . $_.'_view' } @{$self->valid_types} };
+
+ my $right = $right_for->{ $self->type };
+ $right ||= 'DOES_NOT_EXIST';
+
+ $::auth->assert($right);
+}
+
+sub check_auth_for_edit {
+ my ($self) = @_;
+
+ my $right_for = { map { $_ => $_.'_edit' } @{$self->valid_types} };
+
+ my $right = $right_for->{ $self->type };
+ $right ||= 'DOES_NOT_EXIST';
+
+ $::auth->assert($right);
+}
+
+# build the selection box for contacts
+#
+# Needed, if customer/vendor changed.
+sub build_contact_select {
+ my ($self) = @_;
+
+ select_tag('order.cp_id', [ $self->order->{$self->cv}->contacts ],
+ value_key => 'cp_id',
+ title_key => 'full_name_dep',
+ default => $self->order->cp_id,
+ with_empty => 1,
+ style => 'width: 300px',
+ );
+}
+
+# build the selection box for the additional billing address
+#
+# Needed, if customer/vendor changed.
+sub build_billing_address_select {
+ my ($self) = @_;
+
+ return '' if $self->cv ne 'customer';
+
+ select_tag('order.billing_address_id',
+ [ {displayable_id => '', id => ''}, $self->order->{$self->cv}->additional_billing_addresses ],
+ value_key => 'id',
+ title_key => 'displayable_id',
+ default => $self->order->billing_address_id,
+ with_empty => 0,
+ style => 'width: 300px',
+ );
+}
+
+# build the selection box for shiptos
+#
+# Needed, if customer/vendor changed.
+sub build_shipto_select {
+ my ($self) = @_;
+
+ select_tag('order.shipto_id',
+ [ {displayable_id => t8("No/individual shipping address"), shipto_id => ''}, $self->order->{$self->cv}->shipto ],
+ value_key => 'shipto_id',
+ title_key => 'displayable_id',
+ default => $self->order->shipto_id,
+ with_empty => 0,
+ style => 'width: 300px',
+ );
+}
+
+# build the inputs for the cusom shipto dialog
+#
+# Needed, if customer/vendor changed.
+sub build_shipto_inputs {
+ my ($self) = @_;
+
+ my $content = $self->p->render('common/_ship_to_dialog',
+ vc_obj => $self->order->customervendor,
+ cs_obj => $self->order->custom_shipto,
+ cvars => $self->order->custom_shipto->cvars_by_config,
+ id_selector => '#order_shipto_id');
+
+ div_tag($content, id => 'shipto_inputs');
+}
+
+# render the info line for business
+#
+# Needed, if customer/vendor changed.
+sub build_business_info_row
+{
+ $_[0]->p->render('order/tabs/_business_info_row', SELF => $_[0]);
+}
+
+# build the rows for displaying taxes
+#
+# Called if amounts where recalculated and redisplayed.
+sub build_tax_rows {
+ my ($self) = @_;
+
+ my $rows_as_html;
+ foreach my $tax (sort { $a->{tax}->rate cmp $b->{tax}->rate } @{ $self->{taxes} }) {
+ $rows_as_html .= $self->p->render('order/tabs/_tax_row', TAX => $tax, TAXINCLUDED => $self->order->taxincluded);
+ }
+ return $rows_as_html;
+}
+
+
+sub render_price_dialog {
+ my ($self, $record_item) = @_;
+
+ my $price_source = SL::PriceSource->new(record_item => $record_item, record => $self->order);
+
+ $self->js
+ ->run(
+ 'kivi.io.price_chooser_dialog',
+ t8('Available Prices'),
+ $self->render('order/tabs/_price_sources_dialog', { output => 0 }, price_source => $price_source)
+ )
+ ->reinit_widgets;
+
+# if (@errors) {
+# $self->js->text('#dialog_flash_error_content', join ' ', @errors);
+# $self->js->show('#dialog_flash_error');
+# }
+
+ $self->js->render;
+}
+
+sub load_order {
+ my ($self) = @_;
+
+ return if !$::form->{id};
+
+ $self->order(SL::DB::Order->new(id => $::form->{id})->load);
+
+ # Add an empty custom shipto to the order, so that the dialog can render the cvar inputs.
+ # You need a custom shipto object to call cvars_by_config to get the cvars.
+ $self->order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => [])) if !$self->order->custom_shipto;
+
+ return $self->order;
+}
+
+# load or create a new order object
+#
+# And assign changes from the form to this object.
+# If the order is loaded from db, check if items are deleted in the form,
+# remove them form the object and collect them for removing from db on saving.
+# Then create/update items from form (via make_item) and add them.
+sub make_order {
+ my ($self) = @_;
+
+ # add_items adds items to an order with no items for saving, but they cannot
+ # be retrieved via items until the order is saved. Adding empty items to new
+ # order here solves this problem.
+ my $order;
+ $order = SL::DB::Order->new(id => $::form->{id})->load(with => [ 'orderitems', 'orderitems.part' ]) if $::form->{id};
+ $order ||= SL::DB::Order->new(orderitems => [],
+ quotation => (any { $self->type eq $_ } (sales_quotation_type(), request_quotation_type())),
+ currency_id => $::instance_conf->get_currency_id(),);
+
+ my $cv_id_method = $self->cv . '_id';
+ if (!$::form->{id} && $::form->{$cv_id_method}) {
+ $order->$cv_id_method($::form->{$cv_id_method});
+ setup_order_from_cv($order);
+ }
+
+ my $form_orderitems = delete $::form->{order}->{orderitems};
+ my $form_periodic_invoices_config = delete $::form->{order}->{periodic_invoices_config};
+
+ $order->assign_attributes(%{$::form->{order}});
+
+ $self->setup_custom_shipto_from_form($order, $::form);
+
+ if (my $periodic_invoices_config_attrs = $form_periodic_invoices_config ? SL::YAML::Load($form_periodic_invoices_config) : undef) {
+ my $periodic_invoices_config = $order->periodic_invoices_config || $order->periodic_invoices_config(SL::DB::PeriodicInvoicesConfig->new);
+ $periodic_invoices_config->assign_attributes(%$periodic_invoices_config_attrs);
+ }
+
+ # remove deleted items
+ $self->item_ids_to_delete([]);
+ foreach my $idx (reverse 0..$#{$order->orderitems}) {
+ my $item = $order->orderitems->[$idx];
+ if (none { $item->id == $_->{id} } @{$form_orderitems}) {
+ splice @{$order->orderitems}, $idx, 1;
+ push @{$self->item_ids_to_delete}, $item->id;
+ }
+ }
+
+ my @items;
+ my $pos = 1;
+ foreach my $form_attr (@{$form_orderitems}) {
+ my $item = make_item($order, $form_attr);
+ $item->position($pos);
+ push @items, $item;
+ $pos++;
+ }
+ $order->add_items(grep {!$_->id} @items);
+
+ return $order;
+}
+
+# create or update items from form
+#
+# Make item objects from form values. For items already existing read from db.
+# Create a new item else. And assign attributes.
+sub make_item {
+ my ($record, $attr) = @_;
+
+ my $item;
+ $item = first { $_->id == $attr->{id} } @{$record->items} if $attr->{id};
+
+ my $is_new = !$item;
+
+ # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
+ # they cannot be retrieved via custom_variables until the order/orderitem is
+ # saved. Adding empty custom_variables to new orderitem here solves this problem.
+ $item ||= SL::DB::OrderItem->new(custom_variables => []);
+
+ $item->assign_attributes(%$attr);
+
+ if ($is_new) {
+ my $texts = get_part_texts($item->part, $record->language_id);
+ $item->longdescription($texts->{longdescription}) if !defined $attr->{longdescription};
+ $item->project_id($record->globalproject_id) if !defined $attr->{project_id};
+ $item->lastcost($record->is_sales ? $item->part->lastcost : 0) if !defined $attr->{lastcost_as_number};
+ }
+
+ return $item;
+}
+
+# create a new item
+#
+# This is used to add one item
+sub new_item {
+ my ($record, $attr) = @_;
+
+ my $item = SL::DB::OrderItem->new;
+
+ # Remove attributes where the user left or set the inputs empty.
+ # So these attributes will be undefined and we can distinguish them
+ # from zero later on.
+ for (qw(qty_as_number sellprice_as_number discount_as_percent)) {
+ delete $attr->{$_} if $attr->{$_} eq '';
+ }
+
+ $item->assign_attributes(%$attr);
+
+ my $part = SL::DB::Part->new(id => $attr->{parts_id})->load;
+ my $price_source = SL::PriceSource->new(record_item => $item, record => $record);
+
+ $item->unit($part->unit) if !$item->unit;
+
+ my $price_src;
+ if ( $part->is_assortment ) {
+ # add assortment items with price 0, as the components carry the price
+ $price_src = $price_source->price_from_source("");
+ $price_src->price(0);
+ } elsif (defined $item->sellprice) {
+ $price_src = $price_source->price_from_source("");
+ $price_src->price($item->sellprice);
+ } else {
+ $price_src = $price_source->best_price
+ ? $price_source->best_price
+ : $price_source->price_from_source("");
+ $price_src->price($::form->round_amount($price_src->price / $record->exchangerate, 5)) if $record->exchangerate;
+ $price_src->price(0) if !$price_source->best_price;
+ }
+
+ my $discount_src;
+ if (defined $item->discount) {
+ $discount_src = $price_source->discount_from_source("");
+ $discount_src->discount($item->discount);
+ } else {
+ $discount_src = $price_source->best_discount
+ ? $price_source->best_discount
+ : $price_source->discount_from_source("");
+ $discount_src->discount(0) if !$price_source->best_discount;
+ }
+
+ my %new_attr;
+ $new_attr{part} = $part;
+ $new_attr{description} = $part->description if ! $item->description;
+ $new_attr{qty} = 1.0 if ! $item->qty;
+ $new_attr{price_factor_id} = $part->price_factor_id if ! $item->price_factor_id;
+ $new_attr{sellprice} = $price_src->price;
+ $new_attr{discount} = $discount_src->discount;
+ $new_attr{active_price_source} = $price_src;
+ $new_attr{active_discount_source} = $discount_src;
+ $new_attr{longdescription} = $part->notes if ! defined $attr->{longdescription};
+ $new_attr{project_id} = $record->globalproject_id;
+ $new_attr{lastcost} = $record->is_sales ? $part->lastcost : 0;
+
+ # add_custom_variables adds cvars to an orderitem with no cvars for saving, but
+ # they cannot be retrieved via custom_variables until the order/orderitem is
+ # saved. Adding empty custom_variables to new orderitem here solves this problem.
+ $new_attr{custom_variables} = [];
+
+ my $texts = get_part_texts($part, $record->language_id, description => $new_attr{description}, longdescription => $new_attr{longdescription});
+
+ $item->assign_attributes(%new_attr, %{ $texts });
+
+ return $item;
+}
+
+sub setup_order_from_cv {
+ my ($order) = @_;
+
+ $order->$_($order->customervendor->$_) for (qw(taxzone_id payment_id delivery_term_id currency_id language_id));
+
+ $order->intnotes($order->customervendor->notes);
+
+ return if !$order->is_sales;
+
+ $order->salesman_id($order->customer->salesman_id || SL::DB::Manager::Employee->current->id);
+ $order->taxincluded(defined($order->customer->taxincluded_checked)
+ ? $order->customer->taxincluded_checked
+ : $::myconfig{taxincluded_checked});
+
+ my $address = $order->customer->default_billing_address;;
+ $order->billing_address_id($address ? $address->id : undef);
+}
+
+# setup custom shipto from form
+#
+# The dialog returns form variables starting with 'shipto' and cvars starting
+# with 'shiptocvar_'.
+# Mark it to be deleted if a shipto from master data is selected
+# (i.e. order has a shipto).
+# Else, update or create a new custom shipto. If the fields are empty, it
+# will not be saved on save.
+sub setup_custom_shipto_from_form {
+ my ($self, $order, $form) = @_;
+
+ if ($order->shipto) {
+ $self->is_custom_shipto_to_delete(1);
+ } else {
+ my $custom_shipto = $order->custom_shipto || $order->custom_shipto(SL::DB::Shipto->new(module => 'OE', custom_variables => []));
+
+ my $shipto_cvars = {map { my ($key) = m{^shiptocvar_(.+)}; $key => delete $form->{$_}} grep { m{^shiptocvar_} } keys %$form};
+ my $shipto_attrs = {map { $_ => delete $form->{$_}} grep { m{^shipto} } keys %$form};
+
+ $custom_shipto->assign_attributes(%$shipto_attrs);
+ $custom_shipto->cvar_by_name($_)->value($shipto_cvars->{$_}) for keys %$shipto_cvars;
+ }
+}
+
+# recalculate prices and taxes
+#
+# Using the PriceTaxCalculator. Store linetotals in the item objects.
+sub recalc {
+ my ($self) = @_;
+
+ my %pat = $self->order->calculate_prices_and_taxes();
+
+ $self->{taxes} = [];
+ foreach my $tax_id (keys %{ $pat{taxes_by_tax_id} }) {
+ my $netamount = sum0 map { $pat{amounts}->{$_}->{amount} } grep { $pat{amounts}->{$_}->{tax_id} == $tax_id } keys %{ $pat{amounts} };
+
+ push(@{ $self->{taxes} }, { amount => $pat{taxes_by_tax_id}->{$tax_id},
+ netamount => $netamount,
+ tax => SL::DB::Tax->new(id => $tax_id)->load });
+ }
+ pairwise { $a->{linetotal} = $b->{linetotal} } @{$self->order->items_sorted}, @{$pat{items}};
+}
+
+# get data for saving, printing, ..., that is not changed in the form
+#
+# Only cvars for now.
+sub get_unalterable_data {
+ my ($self) = @_;
+
+ foreach my $item (@{ $self->order->items }) {
+ # autovivify all cvars that are not in the form (cvars_by_config can do it).
+ # workaround to pre-parse number-cvars (parse_custom_variable_values does not parse number values).
+ foreach my $var (@{ $item->cvars_by_config }) {
+ $var->unparsed_value($::form->parse_amount(\%::myconfig, $var->{__unparsed_value})) if ($var->config->type eq 'number' && exists($var->{__unparsed_value}));
+ }
+ $item->parse_custom_variable_values;
+ }
+}
+
+# delete the order
+#
+# And remove related files in the spool directory
+sub delete {
+ my ($self) = @_;
+
+ my $errors = [];
+ my $db = $self->order->db;
+
+ $db->with_transaction(
+ sub {
+ my @spoolfiles = grep { $_ } map { $_->spoolfile } @{ SL::DB::Manager::Status->get_all(where => [ trans_id => $self->order->id ]) };
+ $self->order->delete;
+ my $spool = $::lx_office_conf{paths}->{spool};
+ unlink map { "$spool/$_" } @spoolfiles if $spool;
+
+ $self->save_history('DELETED');
+
+ 1;
+ }) || push(@{$errors}, $db->error);
+
+ return $errors;
+}
+
+# save the order
+#
+# And delete items that are deleted in the form.
+sub save {