Projekt

Allgemein

Profil

Herunterladen (64,5 KB) Statistiken
| Zweig: | Markierung: | Revision:
package SL::Controller::Part;

use strict;
use parent qw(SL::Controller::Base);

use Carp;
use Clone qw(clone);
use Data::Dumper;
use DateTime;
use File::Temp;
use List::Util qw(sum);
use List::UtilsBy qw(extract_by);
use POSIX qw(strftime);
use Text::CSV_XS;

use SL::CVar;
use SL::Controller::Helper::GetModels;
use SL::DB::Helper::ValidateAssembly qw(validate_assembly);
use SL::DB::History;
use SL::DB::Part;
use SL::DB::PartsGroup;
use SL::DB::PriceRuleItem;
use SL::DB::Shop;
use SL::Helper::Flash;
use SL::JSON;
use SL::Locale::String qw(t8);
use SL::MoreCommon qw(save_form);
use SL::Presenter::EscapedText qw(escape is_escaped);
use SL::Presenter::Part;
use SL::Presenter::Tag qw(select_tag);

use Rose::Object::MakeMethods::Generic (
'scalar --get_set_init' => [ qw(parts models part p warehouses multi_items_models
makemodels shops_not_assigned
customerprices
orphaned
assortment assortment_items assembly assembly_items
all_pricegroups all_translations all_partsgroups all_units
all_buchungsgruppen all_payment_terms all_warehouses
parts_classification_filter
all_languages all_units all_price_factors) ],
'scalar' => [ qw(warehouse bin stock_amounts journal) ],
);

# safety
__PACKAGE__->run_before(sub { $::auth->assert('part_service_assembly_edit') },
except => [ qw(ajax_autocomplete part_picker_search part_picker_result) ]);

__PACKAGE__->run_before(sub { $::auth->assert('developer') },
only => [ qw(test_page) ]);

__PACKAGE__->run_before('check_part_id', only => [ qw(edit delete) ]);

# actions for editing parts
#
sub action_add_part {
my ($self, %params) = @_;

$self->part( SL::DB::Part->new_part );
$self->add;
};

sub action_add_service {
my ($self, %params) = @_;

$self->part( SL::DB::Part->new_service );
$self->add;
};

sub action_add_assembly {
my ($self, %params) = @_;

$self->part( SL::DB::Part->new_assembly );
$self->add;
};

sub action_add_assortment {
my ($self, %params) = @_;

$self->part( SL::DB::Part->new_assortment );
$self->add;
};

sub action_add_from_record {
my ($self) = @_;

check_has_valid_part_type($::form->{part}{part_type});

die 'parts_classification_type must be "sales" or "purchases"'
unless $::form->{parts_classification_type} =~ m/^(sales|purchases)$/;

$self->parse_form;
$self->add;
}

sub action_add {
my ($self) = @_;

check_has_valid_part_type($::form->{part_type});

$self->action_add_part if $::form->{part_type} eq 'part';
$self->action_add_service if $::form->{part_type} eq 'service';
$self->action_add_assembly if $::form->{part_type} eq 'assembly';
$self->action_add_assortment if $::form->{part_type} eq 'assortment';
};

sub action_save {
my ($self, %params) = @_;

# checks that depend only on submitted $::form
$self->check_form or return $self->js->render;

my $is_new = !$self->part->id; # $ part gets loaded here

# check that the part hasn't been modified
unless ( $is_new ) {
$self->check_part_not_modified or
return $self->js->error(t8('The document has been changed by another user. Please reopen it in another window and copy the changes to the new window'))->render;
}

if ( $is_new
&& $::form->{part}{partnumber}
&& SL::DB::Manager::Part->find_by(partnumber => $::form->{part}{partnumber})
) {
return $self->js->error(t8('The partnumber is already being used'))->render;
}

$self->parse_form;

my @errors = $self->part->validate;
return $self->js->error(@errors)->render if @errors;

if ($is_new) {
# Ensure CVars that should be enabled by default actually are when
# creating new parts.
my @default_valid_configs =
grep { ! $_->{flag_defaults_to_invalid} }
grep { $_->{module} eq 'IC' }
@{ CVar->get_configs() };

$::form->{"cvar_" . $_->{name} . "_valid"} = 1 for @default_valid_configs;
} else {
$self->{lastcost_modified} = $self->check_lastcost_modified;
}

# $self->part has been loaded, parsed and validated without errors and is ready to be saved
$self->part->db->with_transaction(sub {

$self->part->save(cascade => 1);
$self->part->set_lastcost_assemblies_and_assortiments if $self->{lastcost_modified};

SL::DB::History->new(
trans_id => $self->part->id,
snumbers => 'partnumber_' . $self->part->partnumber,
employee_id => SL::DB::Manager::Employee->current->id,
what_done => 'part',
addition => 'SAVED',
)->save();

CVar->save_custom_variables(
dbh => $self->part->db->dbh,
module => 'IC',
trans_id => $self->part->id,
variables => $::form, # $::form->{cvar} would be nicer
save_validity => 1,
);

1;
}) or return $self->js->error(t8('The item couldn\'t be saved!') . " " . $self->part->db->error )->render;

flash_later('info', $is_new ? t8('The item has been created.') . " " . $self->part->displayable_name : t8('The item has been saved.'));

if ( $::form->{callback} ) {
$self->redirect_to($::form->unescape($::form->{callback}) . '&new_parts_id=' . $self->part->id);

} else {
# default behaviour after save: reload item, this also resets last_modification!
$self->redirect_to(controller => 'Part', action => 'edit', 'part.id' => $self->part->id);
}
}

sub action_save_and_purchase_order {
my ($self) = @_;

my $session_value;
if (1 == scalar @{$self->part->makemodels}) {
my $prepared_form = Form->new('');
$prepared_form->{vendor_id} = $self->part->makemodels->[0]->make;
$session_value = $::auth->save_form_in_session(form => $prepared_form);
}

$::form->{callback} = $self->url_for(
controller => 'Order',
action => 'return_from_create_part',
type => 'purchase_order',
previousform => $session_value,
);

$self->_run_action('save');
}

sub action_abort {
my ($self) = @_;

if ( $::form->{callback} ) {
$self->redirect_to($::form->unescape($::form->{callback}));
}
}

sub action_delete {
my ($self) = @_;

my $db = $self->part->db; # $self->part has a get_set_init on $::form

my $partnumber = $self->part->partnumber; # remember for history log

$db->do_transaction(
sub {

# delete part, together with relationships that don't already
# have an ON DELETE CASCADE, e.g. makemodel and translation.
$self->part->delete(cascade => 1);

SL::DB::History->new(
trans_id => $self->part->id,
snumbers => 'partnumber_' . $partnumber,
employee_id => SL::DB::Manager::Employee->current->id,
what_done => 'part',
addition => 'DELETED',
)->save();
1;
}) or return $self->js->error(t8('The item couldn\'t be deleted!') . " " . $self->part->db->error)->render;

flash_later('info', t8('The item has been deleted.'));
if ( $::form->{callback} ) {
$self->redirect_to($::form->unescape($::form->{callback}));
} else {
$self->redirect_to(controller => 'ic.pl', action => 'search', searchitems => 'article');
}
}

sub action_use_as_new {
my ($self, %params) = @_;

my $oldpart = SL::DB::Manager::Part->find_by( id => $::form->{old_id}) or die "can't find old part";
$::form->{oldpartnumber} = $oldpart->partnumber;

$self->part($oldpart->clone_and_reset_deep);
$self->parse_form(use_as_new => 1);
$self->part->partnumber(undef);
$self->render_form(use_as_new => 1);
}

sub action_edit {
my ($self, %params) = @_;

$self->render_form;
}

sub render_form {
my ($self, %params) = @_;

$self->_set_javascript;
$self->_setup_form_action_bar;

my (%assortment_vars, %assembly_vars);
%assortment_vars = %{ $self->prepare_assortment_render_vars } if $self->part->is_assortment;
%assembly_vars = %{ $self->prepare_assembly_render_vars } if $self->part->is_assembly;

$params{CUSTOM_VARIABLES} = $params{use_as_new} && $::form->{old_id}
? CVar->get_custom_variables(module => 'IC', trans_id => $::form->{old_id})
: CVar->get_custom_variables(module => 'IC', trans_id => $self->part->id);


if (scalar @{ $params{CUSTOM_VARIABLES} }) {
CVar->render_inputs('variables' => $params{CUSTOM_VARIABLES}, show_disabled_message => 1, partsgroup_id => $self->part->partsgroup_id);
$params{CUSTOM_VARIABLES_FIRST_TAB} = [];
@{ $params{CUSTOM_VARIABLES_FIRST_TAB} } = extract_by { $_->{first_tab} == 1 } @{ $params{CUSTOM_VARIABLES} };
}

my %title_hash = ( part => t8('Edit Part'),
assembly => t8('Edit Assembly'),
service => t8('Edit Service'),
assortment => t8('Edit Assortment'),
);

$self->part->prices([]) unless $self->part->prices;
$self->part->translations([]) unless $self->part->translations;

$self->render(
'part/form',
title => $title_hash{$self->part->part_type},
%assortment_vars,
%assembly_vars,
translations_map => { map { ($_->language_id => $_) } @{$self->part->translations} },
prices_map => { map { ($_->pricegroup_id => $_) } @{$self->part->prices } },
oldpartnumber => $::form->{oldpartnumber},
old_id => $::form->{old_id},
%params,
);
}

sub action_history {
my ($self) = @_;

my $history_entries = SL::DB::Part->new(id => $::form->{part}{id})->history_entries;
$_[0]->render('part/history', { layout => 0 },
history_entries => $history_entries);
}

sub action_inventory {
my ($self) = @_;

$::auth->assert('warehouse_contents');

$self->stock_amounts($self->part->get_simple_stock_sql);
$self->journal($self->part->get_mini_journal);

$_[0]->render('part/_inventory_data', { layout => 0 });
};

sub action_update_item_totals {
my ($self) = @_;

my $part_type = $::form->{part_type};
die unless $part_type =~ /^(assortment|assembly)$/;

my $sellprice_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'sellcost');
my $lastcost_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'lastcost');
my $items_weight_sum = $self->recalc_item_totals(part_type => $part_type, price_type => 'weight');

my $sum_diff = $sellprice_sum-$lastcost_sum;

$self->js
->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
->html('#items_lastcost_sum', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $sum_diff, 2, 0))
->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $sellprice_sum, 2, 0))
->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $lastcost_sum, 2, 0))
->html('#items_weight_sum_basic' , $::form->format_amount(\%::myconfig, $items_weight_sum))
->no_flash_clear->render();
}

sub action_add_multi_assortment_items {
my ($self) = @_;

my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
my $html = $self->render_assortment_items_to_html($item_objects);

$self->js->run('kivi.Part.close_picker_dialogs')
->append('#assortment_rows', $html)
->run('kivi.Part.renumber_positions')
->run('kivi.Part.assortment_recalc')
->render();
}

sub action_add_multi_assembly_items {
my ($self) = @_;

my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
my @checked_objects;
foreach my $item (@{$item_objects}) {
my $errstr = validate_assembly($item->part,$self->part);
$self->js->flash('error',$errstr) if $errstr;
push (@checked_objects,$item) unless $errstr;
}

my $html = $self->render_assembly_items_to_html(\@checked_objects);

$self->js->run('kivi.Part.close_picker_dialogs')
->append('#assembly_rows', $html)
->run('kivi.Part.renumber_positions')
->run('kivi.Part.assembly_recalc')
->render();
}

sub action_add_assortment_item {
my ($self, %params) = @_;

validate_add_items() or return $self->js->error(t8("No part was selected."))->render;

carp('Too many objects passed to add_assortment_item') if @{$::form->{add_items}} > 1;

my $add_item_id = $::form->{add_items}->[0]->{parts_id};
if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assortment_items } ) {
return $self->js->flash('error', t8("This part has already been added."))->render;
};

my $number_of_items = scalar @{$self->assortment_items};
my $item_objects = $self->parse_add_items_to_objects(part_type => 'assortment');
my $html = $self->render_assortment_items_to_html($item_objects, $number_of_items);

push(@{$self->assortment_items}, @{$item_objects});
my $part = SL::DB::Part->new(part_type => 'assortment');
$part->assortment_items(@{$self->assortment_items});
my $items_sellprice_sum = $part->items_sellprice_sum;
my $items_lastcost_sum = $part->items_lastcost_sum;
my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;

$self->js
->append('#assortment_rows' , $html) # append in tbody
->val('.add_assortment_item_input' , '')
->run('kivi.Part.focus_last_assortment_input')
->html("#items_sellprice_sum", $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
->html("#items_lastcost_sum", $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
->html("#items_sum_diff", $::form->format_amount(\%::myconfig, $items_sum_diff, 2, 0))
->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
->html('#items_lastcost_sum_basic', $::form->format_amount(\%::myconfig, $items_lastcost_sum, 2, 0))
->render;
}

sub action_add_assembly_item {
my ($self) = @_;

validate_add_items() or return $self->js->error(t8("No part was selected."))->render;

carp('Too many objects passed to add_assembly_item') if @{$::form->{add_items}} > 1;

my $add_item_id = $::form->{add_items}->[0]->{parts_id};

my $duplicate_warning = 0; # duplicates are allowed, just warn
if ( $add_item_id && grep { $add_item_id == $_->parts_id } @{ $self->assembly_items } ) {
$duplicate_warning++;
};

my $number_of_items = scalar @{$self->assembly_items};
my $item_objects = $self->parse_add_items_to_objects(part_type => 'assembly');
if ($add_item_id ) {
foreach my $item (@{$item_objects}) {
my $errstr = validate_assembly($item->part,$self->part);
return $self->js->flash('error',$errstr)->render if $errstr;
}
}


my $html = $self->render_assembly_items_to_html($item_objects, $number_of_items);

$self->js->flash('info', t8("This part has already been added.")) if $duplicate_warning;

push(@{$self->assembly_items}, @{$item_objects});
my $part = SL::DB::Part->new(part_type => 'assembly');
$part->assemblies(@{$self->assembly_items});
my $items_sellprice_sum = $part->items_sellprice_sum;
my $items_lastcost_sum = $part->items_lastcost_sum;
my $items_sum_diff = $items_sellprice_sum - $items_lastcost_sum;
my $items_weight_sum = $part->items_weight_sum;

$self->js
->append('#assembly_rows', $html) # append in tbody
->val('.add_assembly_item_input' , '')
->run('kivi.Part.focus_last_assembly_input')
->html('#items_sellprice_sum', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
->html('#items_lastcost_sum' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
->html('#items_sum_diff', $::form->format_amount(\%::myconfig, $items_sum_diff , 2, 0))
->html('#items_sellprice_sum_basic', $::form->format_amount(\%::myconfig, $items_sellprice_sum, 2, 0))
->html('#items_lastcost_sum_basic' , $::form->format_amount(\%::myconfig, $items_lastcost_sum , 2, 0))
->html('#items_weight_sum_basic' , $::form->format_amount(\%::myconfig, $items_weight_sum))
->render;
}

sub action_show_multi_items_dialog {
my ($self) = @_;

my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
$search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
$search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};

$_[0]->render('part/_multi_items_dialog', { layout => 0 },
all_partsgroups => SL::DB::Manager::PartsGroup->get_all,
search_term => $search_term
);
}

sub action_multi_items_update_result {
my $max_count = $::form->{limit};

my $count = $_[0]->multi_items_models->count;

if ($count == 0) {
my $text = escape($::locale->text('No results.'));
$_[0]->render($text, { layout => 0 });
} elsif ($max_count && $count > $max_count) {
my $text = escape($::locale->text('Too many results (#1 from #2).', $count, $max_count));
$_[0]->render($text, { layout => 0 });
} else {
my $multi_items = $_[0]->multi_items_models->get;
$_[0]->render('part/_multi_items_result', { layout => 0 },
multi_items => $multi_items);
}
}

sub action_add_makemodel_row {
my ($self) = @_;

my $vendor_id = $::form->{add_makemodel};

my $vendor = SL::DB::Manager::Vendor->find_by(id => $vendor_id) or
return $self->js->error(t8("No vendor selected or found!"))->render;

if ( grep { $vendor_id == $_->make } @{ $self->makemodels } ) {
$self->js->flash('info', t8("This vendor has already been added."));
};

my $position = scalar @{$self->makemodels} + 1;

my $mm = SL::DB::MakeModel->new(# parts_id => $::form->{part}->{id},
make => $vendor->id,
model => '',
lastcost => 0,
sortorder => $position,
) or die "Can't create MakeModel object";

my $row_as_html = $self->p->render('part/_makemodel_row',
makemodel => $mm,
listrow => $position % 2 ? 0 : 1,
);

# after selection focus on the model field in the row that was just added
$self->js
->append('#makemodel_rows', $row_as_html) # append in tbody
->val('.add_makemodel_input', '')
->run('kivi.Part.focus_last_makemodel_input')
->render;
}

sub action_add_customerprice_row {
my ($self) = @_;

my $customer_id = $::form->{add_customerprice};

my $customer = SL::DB::Manager::Customer->find_by(id => $customer_id)
or return $self->js->error(t8("No customer selected or found!"))->render;

if (grep { $customer_id == $_->customer_id } @{ $self->customerprices }) {
$self->js->flash('info', t8("This customer has already been added."));
}

my $position = scalar @{ $self->customerprices } + 1;

my $cu = SL::DB::PartCustomerPrice->new(
customer_id => $customer->id,
customer_partnumber => '',
price => 0,
sortorder => $position,
) or die "Can't create Customerprice object";

my $row_as_html = $self->p->render(
'part/_customerprice_row',
customerprice => $cu,
listrow => $position % 2 ? 0
: 1,
);

$self->js->append('#customerprice_rows', $row_as_html) # append in tbody
->val('.add_customerprice_input', '')
->run('kivi.Part.focus_last_customerprice_input')->render;
}

sub action_reorder_items {
my ($self) = @_;

my $part_type = $::form->{part_type};

my %sort_keys = (
partnumber => sub { $_[0]->part->partnumber },
description => sub { $_[0]->part->description },
qty => sub { $_[0]->qty },
sellprice => sub { $_[0]->part->sellprice },
lastcost => sub { $_[0]->part->lastcost },
partsgroup => sub { $_[0]->part->partsgroup_id ? $_[0]->part->partsgroup->partsgroup : '' },
);

my $method = $sort_keys{$::form->{order_by}};

my @items;
if ($part_type eq 'assortment') {
@items = @{ $self->assortment_items };
} else {
@items = @{ $self->assembly_items };
};

my @to_sort = map { { old_pos => $_->position, order_by => $method->($_) } } @items;
if ($::form->{order_by} =~ /^(qty|sellprice|lastcost)$/) {
if ($::form->{sort_dir}) {
@to_sort = sort { $a->{order_by} <=> $b->{order_by} } @to_sort;
} else {
@to_sort = sort { $b->{order_by} <=> $a->{order_by} } @to_sort;
}
} else {
if ($::form->{sort_dir}) {
@to_sort = sort { $a->{order_by} cmp $b->{order_by} } @to_sort;
} else {
@to_sort = sort { $b->{order_by} cmp $a->{order_by} } @to_sort;
}
};

$self->js->run('kivi.Part.redisplay_items', \@to_sort)->render;
}

sub action_warehouse_changed {
my ($self) = @_;

if ($::form->{warehouse_id} ) {
$self->warehouse(SL::DB::Manager::Warehouse->find_by_or_create(id => $::form->{warehouse_id}));
die unless ref($self->warehouse) eq 'SL::DB::Warehouse';

if ( $self->warehouse->id and @{$self->warehouse->bins} ) {
$self->bin($self->warehouse->bins_sorted->[0]);
$self->js
->html('#bin', $self->build_bin_select)
->focus('#part_bin_id');
return $self->js->render;
}
}

# no warehouse was selected, empty the bin field and reset the id
$self->js
->val('#part_bin_id', undef)
->html('#bin', '');

return $self->js->render;
}

sub action_ajax_autocomplete {
my ($self, %params) = @_;

# if someone types something, and hits enter, assume he entered the full name.
# if something matches, treat that as sole match
# since we need a second get models instance with different filters for that,
# we only modify the original filter temporarily in place
if ($::form->{prefer_exact}) {
local $::form->{filter}{'all::ilike'} = delete local $::form->{filter}{'all:substr:multi::ilike'};
local $::form->{filter}{'all_with_makemodel::ilike'} = delete local $::form->{filter}{'all_with_makemodel:substr:multi::ilike'};
local $::form->{filter}{'all_with_customer_partnumber::ilike'} = delete local $::form->{filter}{'all_with_customer_partnumber:substr:multi::ilike'};

my $exact_models = SL::Controller::Helper::GetModels->new(
controller => $self,
sorted => 0,
paginated => { per_page => 2 },
with_objects => [ qw(unit_obj classification) ],
);
my $exact_matches;
if (1 == scalar @{ $exact_matches = $exact_models->get }) {
$self->parts($exact_matches);
}
}

my @hashes = map {
+{
value => $_->displayable_name,
label => $_->displayable_name,
id => $_->id,
partnumber => $_->partnumber,
description => $_->description,
ean => $_->ean,
part_type => $_->part_type,
unit => $_->unit,
cvars => { map { ($_->config->name => { value => $_->value_as_text, is_valid => $_->is_valid }) } @{ $_->cvars_by_config } },
}
} @{ $self->parts }; # neato: if exact match triggers we don't even need the init_parts

$self->render(\ SL::JSON::to_json(\@hashes), { layout => 0, type => 'json', process => 0 });
}

sub action_test_page {
$_[0]->render('part/test_page', pre_filled_part => SL::DB::Manager::Part->get_first);
}

sub action_part_picker_search {
my ($self) = @_;

my $search_term = $self->models->filtered->laundered->{all_substr_multi__ilike};
$search_term ||= $self->models->filtered->laundered->{all_with_makemodel_substr_multi__ilike};
$search_term ||= $self->models->filtered->laundered->{all_with_customer_partnumber_substr_multi__ilike};

$_[0]->render('part/part_picker_search', { layout => 0 }, search_term => $search_term);
}

sub action_part_picker_result {
$_[0]->render('part/_part_picker_result', { layout => 0 }, parts => $_[0]->parts);
}

sub action_show {
my ($self) = @_;

if ($::request->type eq 'json') {
my $part_hash;
if (!$self->part) {
# TODO error
} else {
$part_hash = $self->part->as_tree;
$part_hash->{cvars} = $self->part->cvar_as_hashref;
}

$self->render(\ SL::JSON::to_json($part_hash), { layout => 0, type => 'json', process => 0 });
}
}

sub action_export_assembly_assortment_components {
my ($self) = @_;

my $bom_or_charge = $self->part->is_assembly ? 'bom' : 'charge';

my @rows = ([
$::locale->text('Partnumber'),
$::locale->text('Description'),
$::locale->text('Type'),
$::locale->text('Classification'),
$::locale->text('Qty'),
$::locale->text('Unit'),
$self->part->is_assembly ? $::locale->text('BOM') : $::locale->text('Charge'),
$::locale->text('Line Total'),
$::locale->text('Sellprice'),
$::locale->text('Lastcost'),
$::locale->text('Partsgroup'),
]);

foreach my $item (@{ $self->part->items }) {
my $part = $item->part;

my @row = (
$part->partnumber,
$part->description,
SL::Presenter::Part::type_abbreviation($part->part_type),
SL::Presenter::Part::classification_abbreviation($part->classification_id),
$item->qty_as_number,
$part->unit,
$item->$bom_or_charge ? $::locale->text('yes') : $::locale->text('no'),
$::form->format_amount(\%::myconfig, $item->linetotal_sellprice, 3, 0),
$part->sellprice_as_number,
$part->lastcost_as_number,
$part->partsgroup ? $part->partsgroup->partsgroup : '',
);

push @rows, \@row;
}

my $csv = Text::CSV_XS->new({
sep_char => ';',
eol => "\n",
binary => 1,
});

my ($file_handle, $file_name) = File::Temp::tempfile;

binmode $file_handle, ":encoding(utf8)";

$csv->print($file_handle, $_) for @rows;

$file_handle->close;

my $type_prefix = $self->part->is_assembly ? 'assembly' : 'assortment';
my $part_number = $self->part->partnumber;
$part_number =~ s{[^[:word:]]+}{_}g;
my $timestamp = strftime('_%Y-%m-%d_%H-%M-%S', localtime());
my $attachment_name = sprintf('%s_components_%s_%s.csv', $type_prefix, $part_number, $timestamp);

$self->send_file(
$file_name,
content_type => 'text/csv',
name => $attachment_name,
);

}

# helper functions
sub validate_add_items {
scalar @{$::form->{add_items}};
}

sub prepare_assortment_render_vars {
my ($self) = @_;

my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
items_lastcost_sum => $self->part->items_lastcost_sum,
assortment_html => $self->render_assortment_items_to_html( \@{$self->part->items} ),
);
$vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};

return \%vars;
}

sub prepare_assembly_render_vars {
my ($self) = @_;

croak("Need assembly item(s) to create a 'save as new' assembly.") unless $self->part->items;

my %vars = ( items_sellprice_sum => $self->part->items_sellprice_sum,
items_lastcost_sum => $self->part->items_lastcost_sum,
assembly_html => $self->render_assembly_items_to_html( \@{ $self->part->items } ),
);
$vars{items_sum_diff} = $vars{items_sellprice_sum} - $vars{items_lastcost_sum};

return \%vars;
}

sub add {
my ($self) = @_;

check_has_valid_part_type($self->part->part_type);

$self->_set_javascript;
$self->_setup_form_action_bar;

my %title_hash = ( part => t8('Add Part'),
assembly => t8('Add Assembly'),
service => t8('Add Service'),
assortment => t8('Add Assortment'),
);

$self->render(
'part/form',
title => $title_hash{$self->part->part_type},
);
}


sub _set_javascript {
my ($self) = @_;
$::request->layout->use_javascript("${_}.js") for qw(kivi.Part kivi.File kivi.PriceRule ckeditor/ckeditor ckeditor/adapters/jquery kivi.ShopPart kivi.Validator);
$::request->layout->add_javascripts_inline("\$(function(){kivi.PriceRule.load_price_rules_for_part(@{[ $self->part->id ]})});") if $self->part->id;
}

sub recalc_item_totals {
my ($self, %params) = @_;

if ( $params{part_type} eq 'assortment' ) {
return 0 unless scalar @{$self->assortment_items};
} elsif ( $params{part_type} eq 'assembly' ) {
return 0 unless scalar @{$self->assembly_items};
} else {
carp "can only calculate sum for assortments and assemblies";
};

my $part = SL::DB::Part->new(part_type => $params{part_type});
if ( $part->is_assortment ) {
$part->assortment_items( @{$self->assortment_items} );
if ( $params{price_type} eq 'lastcost' ) {
return $part->items_lastcost_sum;
} else {
if ( $params{pricegroup_id} ) {
return $part->items_sellprice_sum(pricegroup_id => $params{pricegroup_id});
} else {
return $part->items_sellprice_sum;
};
}
} elsif ( $part->is_assembly ) {
$part->assemblies( @{$self->assembly_items} );
if ( $params{price_type} eq 'weight' ) {
return $part->items_weight_sum;
} elsif ( $params{price_type} eq 'lastcost' ) {
return $part->items_lastcost_sum;
} else {
return $part->items_sellprice_sum;
}
}
}

sub check_part_not_modified {
my ($self) = @_;

return !($::form->{last_modification} && ($self->part->last_modification ne $::form->{last_modification}));

}

sub check_lastcost_modified {
my ($self) = @_;
return abs($self->part->lastcost - $self->part->last_price_update->lastcost) < 0.009 ? undef : 1;
}

sub parse_form {
my ($self, %params) = @_;

my $is_new = !$self->part->id;

my $params = delete($::form->{part}) || { };

delete $params->{id};
$self->part->assign_attributes(%{ $params});
$self->part->bin_id(undef) unless $self->part->warehouse_id;

$self->normalize_text_blocks;

# Only reset items ([]) and rewrite from form if $::form->{assortment_items} isn't empty. This
# will be the case for used assortments when saving, or when a used assortment
# is "used as new"
if ( $self->part->is_assortment and $::form->{assortment_items} and scalar @{$::form->{assortment_items}}) {
$self->part->assortment_items([]);
$self->part->add_assortment_items(@{$self->assortment_items}); # assortment_items has a get_set_init
};

if ( $self->part->is_assembly and $::form->{assembly_items} and @{$::form->{assembly_items}} ) {
$self->part->assemblies([]); # completely rewrite assortments each time
$self->part->add_assemblies( @{ $self->assembly_items } );
};

$self->part->translations([]) unless $params{use_as_new};
$self->parse_form_translations;

$self->part->prices([]);
$self->parse_form_prices;

$self->parse_form_customerprices;
$self->parse_form_makemodels;
}

sub parse_form_prices {
my ($self) = @_;
# only save prices > 0
my $prices = delete($::form->{prices}) || [];
foreach my $price ( @{$prices} ) {
my $sellprice = $::form->parse_amount(\%::myconfig, $price->{price});
next unless $sellprice > 0; # skip negative prices as well
my $p = SL::DB::Price->new(parts_id => $self->part->id,
pricegroup_id => $price->{pricegroup_id},
price => $sellprice,
);
$self->part->add_prices($p);
};
}

sub parse_form_translations {
my ($self) = @_;
# don't add empty translations
my $translations = delete($::form->{translations}) || [];
foreach my $translation ( @{$translations} ) {
next unless $translation->{translation};
my $t = SL::DB::Translation->new( %{$translation} ) or die "Can't create translation";
$self->part->add_translations( $translation );
};
}

sub parse_form_makemodels {
my ($self) = @_;

my $makemodels_map;
if ( $self->part->makemodels ) { # check for new parts or parts without makemodels
$makemodels_map = { map { $_->id => Rose::DB::Object::Helpers::clone($_) } @{$self->part->makemodels} };
};

$self->part->makemodels([]);

my $position = 0;
my $makemodels = delete($::form->{makemodels}) || [];
foreach my $makemodel ( @{$makemodels} ) {
next unless $makemodel->{make};
$position++;
my $vendor = SL::DB::Manager::Vendor->find_by(id => $makemodel->{make}) || die "Can't find vendor from make";

my $id = $makemodels_map->{$makemodel->{id}} ? $makemodels_map->{$makemodel->{id}}->id : undef;
my $mm = SL::DB::MakeModel->new( # parts_id => $self->part->id, # will be assigned by row add_makemodels
id => $id,
make => $makemodel->{make},
model => $makemodel->{model} || '',
part_description => $makemodel->{part_description},
lastcost => $::form->parse_amount(\%::myconfig, $makemodel->{lastcost_as_number}),
sortorder => $position,
);
if ($makemodels_map->{$mm->id} && !$makemodels_map->{$mm->id}->lastupdate && $makemodels_map->{$mm->id}->lastcost == 0 && $mm->lastcost == 0) {
# lastupdate isn't set, original lastcost is 0 and new lastcost is 0
# don't change lastupdate
} elsif ( !$makemodels_map->{$mm->id} && $mm->lastcost == 0 ) {
# new makemodel, no lastcost entered, leave lastupdate empty
} elsif ($makemodels_map->{$mm->id} && $makemodels_map->{$mm->id}->lastcost == $mm->lastcost) {
# lastcost hasn't changed, use original lastupdate
$mm->lastupdate($makemodels_map->{$mm->id}->lastupdate);
} else {
$mm->lastupdate(DateTime->