X-Git-Url: http://wagnertech.de/git?a=blobdiff_plain;f=SL%2FDB%2FOrder.pm;h=fdaa1e8bf2f69ce65546e01ee6c71c5fcbd8a0b5;hb=332b5ec73395d63e194dd5719c77053cb3d1acb5;hp=a605a36892c6b8a473c337320da43f0e32ba45db;hpb=fb03d191f53516cbf1022e755665556e7f1acb82;p=kivitendo-erp.git diff --git a/SL/DB/Order.pm b/SL/DB/Order.pm index a605a3689..fdaa1e8bf 100644 --- a/SL/DB/Order.pm +++ b/SL/DB/Order.pm @@ -6,15 +6,21 @@ use strict; use Carp; use DateTime; use List::Util qw(max); +use List::MoreUtils qw(any); use SL::DB::MetaSetup::Order; use SL::DB::Manager::Order; +use SL::DB::Helper::Attr; +use SL::DB::Helper::AttrHTML; +use SL::DB::Helper::AttrSorted; use SL::DB::Helper::FlattenToForm; use SL::DB::Helper::LinkedRecords; use SL::DB::Helper::PriceTaxCalculator; use SL::DB::Helper::PriceUpdater; use SL::DB::Helper::TransNumberGenerator; +use SL::Locale::String qw(t8); use SL::RecordLinks; +use Rose::DB::Object::Helpers qw(as_tree); __PACKAGE__->meta->add_relationship( orderitems => { @@ -36,11 +42,34 @@ __PACKAGE__->meta->add_relationship( column_map => { id => 'trans_id' }, query_args => [ module => 'OE' ], }, + exchangerate_obj => { + type => 'one to one', + class => 'SL::DB::Exchangerate', + column_map => { currency_id => 'currency_id', transdate => 'transdate' }, + }, + phone_notes => { + type => 'one to many', + class => 'SL::DB::Note', + column_map => { id => 'trans_id' }, + query_args => [ trans_module => 'oe' ], + manager_args => { + with_objects => [ 'employee' ], + sort_by => 'notes.itime', + } + }, ); +SL::DB::Helper::Attr::make(__PACKAGE__, daily_exchangerate => 'numeric'); + __PACKAGE__->meta->initialize; +__PACKAGE__->attr_html('notes'); +__PACKAGE__->attr_sorted('items'); + __PACKAGE__->before_save('_before_save_set_ord_quo_number'); +__PACKAGE__->before_save('_before_save_create_new_project'); +__PACKAGE__->before_save('_before_save_remove_empty_custom_shipto'); +__PACKAGE__->before_save('_before_save_set_custom_shipto_module'); # hooks @@ -56,17 +85,54 @@ sub _before_save_set_ord_quo_number { return 1; } +sub _before_save_create_new_project { + my ($self) = @_; -# methods + # force new project, if not set yet + if ($::instance_conf->get_order_always_project && !$self->globalproject_id && ($self->type eq 'sales_order')) { + + die t8("Error while creating project with project number of new order number, project number #1 already exists!", $self->ordnumber) + if SL::DB::Manager::Project->find_by(projectnumber => $self->ordnumber); + + eval { + my $new_project = SL::DB::Project->new( + projectnumber => $self->ordnumber, + description => $self->customer->name, + customer_id => $self->customer->id, + active => 1, + project_type_id => $::instance_conf->get_project_type_id, + project_status_id => $::instance_conf->get_project_status_id, + ); + $new_project->save; + $self->globalproject_id($new_project->id); + } or die t8('Could not create new project #1', $@); + } + return 1; +} -sub items { goto &orderitems; } -sub items_sorted { +sub _before_save_remove_empty_custom_shipto { + my ($self) = @_; + + $self->custom_shipto(undef) if $self->custom_shipto && $self->custom_shipto->is_empty; + + return 1; +} + +sub _before_save_set_custom_shipto_module { my ($self) = @_; - return [ sort {$a->id <=> $b->id } @{ $self->items } ]; + $self->custom_shipto->module('OE') if $self->custom_shipto; + + return 1; } +# methods + +sub items { goto &orderitems; } +sub add_items { goto &add_orderitems; } +sub record_number { goto &number; } + sub type { my $self = shift; @@ -82,6 +148,20 @@ sub is_type { return shift->type eq shift; } +sub deliverydate { + # oe doesn't have deliverydate, but it does have reqdate. + # But this has a different meaning for sales quotations. + # deliverydate can be used to determine tax if tax_point isn't set. + + return $_[0]->reqdate if $_[0]->type ne 'sales_quotation'; +} + +sub effective_tax_point { + my ($self) = @_; + + return $self->tax_point || $self->deliverydate || $self->transdate; +} + sub displayable_type { my $type = shift->type; @@ -93,12 +173,42 @@ sub displayable_type { die 'invalid type'; } +sub displayable_name { + join ' ', grep $_, map $_[0]->$_, qw(displayable_type record_number); +}; sub is_sales { croak 'not an accessor' if @_ > 1; return !!shift->customer_id; } +sub daily_exchangerate { + my ($self, $val) = @_; + + return 1 if $self->currency_id == $::instance_conf->get_currency_id; + + my $rate = (any { $self->is_type($_) } qw(sales_quotation sales_order)) ? 'buy' + : (any { $self->is_type($_) } qw(request_quotation purchase_order)) ? 'sell' + : undef; + return if !$rate; + + if (defined $val) { + croak t8('exchange rate has to be positive') if $val <= 0; + if (!$self->exchangerate_obj) { + $self->exchangerate_obj(SL::DB::Exchangerate->new( + currency_id => $self->currency_id, + transdate => $self->transdate, + $rate => $val, + )); + } elsif (!defined $self->exchangerate_obj->$rate) { + $self->exchangerate_obj->$rate($val); + } else { + croak t8('exchange rate already exists, no update allowed'); + } + } + return $self->exchangerate_obj->$rate if $self->exchangerate_obj; +} + sub invoices { my $self = shift; my %params = @_; @@ -138,8 +248,23 @@ sub convert_to_invoice { my $invoice; if (!$self->db->with_transaction(sub { require SL::DB::Invoice; - $invoice = SL::DB::Invoice->new_from($self)->post(%params) || die; + $invoice = SL::DB::Invoice->new_from($self, %params)->post || die; $self->link_to_record($invoice); + # TODO extend link_to_record for items, otherwise long-term no d.r.y. + foreach my $item (@{ $invoice->items }) { + foreach (qw(orderitems)) { + if ($item->{"converted_from_${_}_id"}) { + die unless $item->{id}; + RecordLinks->create_links('mode' => 'ids', + 'from_table' => $_, + 'from_ids' => $item->{"converted_from_${_}_id"}, + 'to_table' => 'invoice', + 'to_id' => $item->{id}, + ) || die; + delete $item->{"converted_from_${_}_id"}; + } + } + } $self->update_attributes(closed => 1); 1; })) { @@ -152,25 +277,248 @@ sub convert_to_invoice { sub convert_to_delivery_order { my ($self, @args) = @_; - my ($delivery_order, $custom_shipto); + my $delivery_order; if (!$self->db->with_transaction(sub { require SL::DB::DeliveryOrder; - ($delivery_order, $custom_shipto) = SL::DB::DeliveryOrder->new_from($self, @args); + $delivery_order = SL::DB::DeliveryOrder->new_from($self, @args); $delivery_order->save; - $custom_shipto->save if $custom_shipto; $self->link_to_record($delivery_order); - $self->update_attributes(delivered => 1); + # TODO extend link_to_record for items, otherwise long-term no d.r.y. + foreach my $item (@{ $delivery_order->items }) { + foreach (qw(orderitems)) { # expand if needed (delivery_order_items) + if ($item->{"converted_from_${_}_id"}) { + die unless $item->{id}; + RecordLinks->create_links('dbh' => $self->db->dbh, + 'mode' => 'ids', + 'from_table' => $_, + 'from_ids' => $item->{"converted_from_${_}_id"}, + 'to_table' => 'delivery_order_items', + 'to_id' => $item->{id}, + ) || die; + delete $item->{"converted_from_${_}_id"}; + } + } + } + + $self->update_attributes(delivered => 1) unless $::instance_conf->get_shipped_qty_require_stock_out; 1; })) { - return wantarray ? () : undef; + return undef; + } + + return $delivery_order; +} + +sub _clone_orderitem_cvar { + my ($cvar) = @_; + + my $cloned = $_->clone_and_reset; + $cloned->sub_module('orderitems'); + + return $cloned; +} + +sub new_from { + my ($class, $source, %params) = @_; + + croak("Unsupported source object type '" . ref($source) . "'") unless ref($source) eq 'SL::DB::Order'; + croak("A destination type must be given as parameter") unless $params{destination_type}; + + my $destination_type = delete $params{destination_type}; + + my @from_tos = ( + { from => 'sales_quotation', to => 'sales_order', abbr => 'sqso' }, + { from => 'request_quotation', to => 'purchase_order', abbr => 'rqpo' }, + { from => 'sales_quotation', to => 'sales_quotation', abbr => 'sqsq' }, + { from => 'sales_order', to => 'sales_order', abbr => 'soso' }, + { from => 'request_quotation', to => 'request_quotation', abbr => 'rqrq' }, + { from => 'purchase_order', to => 'purchase_order', abbr => 'popo' }, + { from => 'sales_order', to => 'purchase_order', abbr => 'sopo' }, + { from => 'purchase_order', to => 'sales_order', abbr => 'poso' }, + { from => 'sales_order', to => 'sales_quotation', abbr => 'sosq' }, + { from => 'purchase_order', to => 'request_quotation', abbr => 'porq' }, + ); + my $from_to = (grep { $_->{from} eq $source->type && $_->{to} eq $destination_type} @from_tos)[0]; + croak("Cannot convert from '" . $source->type . "' to '" . $destination_type . "'") if !$from_to; + + my $is_abbr_any = sub { + any { $from_to->{abbr} eq $_ } @_; + }; + + my ($item_parent_id_column, $item_parent_column); + + if (ref($source) eq 'SL::DB::Order') { + $item_parent_id_column = 'trans_id'; + $item_parent_column = 'order'; + } + + my %args = ( map({ ( $_ => $source->$_ ) } qw(amount cp_id currency_id cusordnumber customer_id delivery_customer_id delivery_term_id delivery_vendor_id + department_id exchangerate globalproject_id intnotes marge_percent marge_total language_id netamount notes + ordnumber payment_id quonumber reqdate salesman_id shippingpoint shipvia taxincluded tax_point taxzone_id + transaction_description vendor_id billing_address_id + )), + quotation => !!($destination_type =~ m{quotation$}), + closed => 0, + delivered => 0, + transdate => DateTime->today_local, + employee => SL::DB::Manager::Employee->current, + ); + + if ( $is_abbr_any->(qw(sopo poso)) ) { + $args{ordnumber} = undef; + $args{quonumber} = undef; + $args{reqdate} = DateTime->today_local->next_workday(); + } + if ( $is_abbr_any->(qw(sopo)) ) { + $args{customer_id} = undef; + $args{salesman_id} = undef; + $args{payment_id} = undef; + $args{delivery_term_id} = undef; + } + if ( $is_abbr_any->(qw(poso)) ) { + $args{vendor_id} = undef; + } + if ( $is_abbr_any->(qw(soso)) ) { + $args{periodic_invoices_config} = $source->periodic_invoices_config->clone_and_reset if $source->periodic_invoices_config; + } + if ( $is_abbr_any->(qw(sosq porq)) ) { + $args{ordnumber} = undef; + $args{quonumber} = undef; + $args{reqdate} = DateTime->today_local->next_workday(); + } + + # Custom shipto addresses (the ones specific to the sales/purchase + # record and not to the customer/vendor) are only linked from + # shipto → order. Meaning order.shipto_id + # will not be filled in that case. + if (!$source->shipto_id && $source->id) { + $args{custom_shipto} = $source->custom_shipto->clone($class) if $source->can('custom_shipto') && $source->custom_shipto; + + } else { + $args{shipto_id} = $source->shipto_id; + } + + my $order = $class->new(%args); + $order->assign_attributes(%{ $params{attributes} }) if $params{attributes}; + my $items = delete($params{items}) || $source->items_sorted; + + my %item_parents; + + my @items = map { + my $source_item = $_; + my $source_item_id = $_->$item_parent_id_column; + my @custom_variables = map { _clone_orderitem_cvar($_) } @{ $source_item->custom_variables }; + + $item_parents{$source_item_id} ||= $source_item->$item_parent_column; + my $item_parent = $item_parents{$source_item_id}; + + my $current_oe_item = SL::DB::OrderItem->new(map({ ( $_ => $source_item->$_ ) } + qw(active_discount_source active_price_source base_qty cusordnumber + description discount lastcost longdescription + marge_percent marge_price_factor marge_total + ordnumber parts_id price_factor price_factor_id pricegroup_id + project_id qty reqdate sellprice serialnumber ship subtotal transdate unit + optional + )), + custom_variables => \@custom_variables, + ); + if ( $is_abbr_any->(qw(sopo)) ) { + $current_oe_item->sellprice($source_item->lastcost); + $current_oe_item->discount(0); + } + if ( $is_abbr_any->(qw(poso)) ) { + $current_oe_item->lastcost($source_item->sellprice); + } + $current_oe_item->{"converted_from_orderitems_id"} = $_->{id} if ref($item_parent) eq 'SL::DB::Order'; + $current_oe_item; + } @{ $items }; + + @items = grep { $params{item_filter}->($_) } @items if $params{item_filter}; + @items = grep { $_->qty * 1 } @items if $params{skip_items_zero_qty}; + @items = grep { $_->qty >=0 } @items if $params{skip_items_negative_qty}; + + $order->items(\@items); + + return $order; +} + +sub new_from_multi { + my ($class, $sources, %params) = @_; + + croak("Unsupported object type in sources") if any { ref($_) !~ m{SL::DB::Order} } @$sources; + croak("Cannot create order for purchase records") if any { !$_->is_sales } @$sources; + croak("Cannot create order from source records of different customers") if any { $_->customer_id != $sources->[0]->customer_id } @$sources; + + # bb: todo: check shipto: is it enough to check the ids or do we have to compare the entries? + if (delete $params{check_same_shipto}) { + die "check same shipto address is not implemented yet"; + die "Source records do not have the same shipto" if 1; + } + + # sort sources + if (defined $params{sort_sources_by}) { + my $sort_by = delete $params{sort_sources_by}; + if ($sources->[0]->can($sort_by)) { + $sources = [ sort { $a->$sort_by cmp $b->$sort_by } @$sources ]; + } else { + die "Cannot sort source records by $sort_by"; + } + } + + # set this entries to undef that yield different information + my %attributes; + foreach my $attr (qw(ordnumber transdate reqdate tax_point taxincluded shippingpoint + shipvia notes closed delivered reqdate quonumber + cusordnumber proforma transaction_description + order_probability expected_billing_date)) { + $attributes{$attr} = undef if any { ($sources->[0]->$attr//'') ne ($_->$attr//'') } @$sources; + } + foreach my $attr (qw(cp_id currency_id salesman_id department_id + delivery_customer_id delivery_vendor_id shipto_id + globalproject_id exchangerate)) { + $attributes{$attr} = undef if any { ($sources->[0]->$attr||0) != ($_->$attr||0) } @$sources; + } + + # set this entries from customer that yield different information + foreach my $attr (qw(language_id taxzone_id payment_id delivery_term_id)) { + $attributes{$attr} = $sources->[0]->customervendor->$attr if any { ($sources->[0]->$attr||0) != ($_->$attr||0) } @$sources; } + $attributes{intnotes} = $sources->[0]->customervendor->notes if any { ($sources->[0]->intnotes//'') ne ($_->intnotes//'') } @$sources; - return wantarray ? ($delivery_order, $custom_shipto) : $delivery_order; + # no periodic invoice config for new order + $attributes{periodic_invoices_config} = undef; + + # set emplyee to the current one + $attributes{employee} = SL::DB::Manager::Employee->current; + + # copy global ordnumber, transdate, cusordnumber into item scope + # unless already present there + foreach my $attr (qw(ordnumber transdate cusordnumber)) { + foreach my $src (@$sources) { + foreach my $item (@{ $src->items_sorted }) { + $item->$attr($src->$attr) if !$item->$attr; + } + } + } + + # collect items + my @items; + push @items, @{$_->items_sorted} for @$sources; + # make order from first source and all items + my $order = $class->new_from($sources->[0], + destination_type => 'sales_order', + attributes => \%attributes, + items => \@items, + %params); + + return $order; } sub number { my $self = shift; + return if !$self->type; + my %number_method = ( sales_order => 'ordnumber', sales_quotation => 'quonumber', @@ -181,14 +529,32 @@ sub number { return $self->${ \ $number_method{$self->type} }(@_); } +sub customervendor { + $_[0]->is_sales ? $_[0]->customer : $_[0]->vendor; +} + sub date { goto &transdate; } +sub digest { + my ($self) = @_; + + sprintf "%s %s %s (%s)", + $self->number, + $self->customervendor->name, + $self->amount_as_number, + $self->date->to_kivitendo; +} + 1; __END__ +=pod + +=encoding utf8 + =head1 NAME SL::DB::Order - Order Datenbank Objekt. @@ -215,6 +581,30 @@ Returns one of the following string types: Returns true if the order is of the given type. +=head2 C + +Gets or sets the exchangerate object's value. This is the value from the +table C depending on the order's currency, the transdate and +if it is a sales or purchase order. + +The order object (respectively the table C) has an own column +C which can be get or set with the accessor C. + +The idea is to drop the legacy table C in the future and to +give all relevant tables it's own C column. + +So, this method is here if you need to access the "legacy" exchangerate via +an order object. + +=over 4 + +=item C<$val> + +(optional) If given, the exchangerate in the "legacy" table is set to this +value, depending on currency, transdate and sales or purchase. + +=back + =head2 C Creates a new delivery order with C<$self> as the basis by calling @@ -226,16 +616,8 @@ C, and C<$self> is saved. The arguments in C<%params> are passed to L. -Returns C on failure. Otherwise the return value depends on the -context. In list context the new delivery order and a shipto instance -will be returned. In scalar instance only the delivery order instance -is returned. - -Custom shipto addresses (the ones specific to the sales/purchase -record and not to the customer/vendor) are only linked from C -to C. Meaning C will not -be filled in that case. That's why a separate shipto object is created -and returned. +Returns C on failure. Otherwise the new delivery order will be +returned. =head2 C @@ -244,7 +626,7 @@ L. That invoice is posted, and C<$self> is linked to the new invoice via L. C<$self>'s C attribute is set to C, and C<$self> is saved. -The arguments in C<%params> are passed to L. +The arguments in C<%params> are passed to L. Returns the new invoice instance on success and C on failure. The whole process is run inside a transaction. On failure @@ -252,15 +634,80 @@ nothing is created or changed in the database. At the moment only sales quotations and sales orders can be converted. -=head2 C +=head2 C + +Creates a new C instance and copies as much +information from C<$source> as possible. At the moment only records with the +same destination type as the source type and sales orders from +sales quotations and purchase orders from requests for quotations can be +created. + +The C field will be set to the current date. + +The conversion copies the order items as well. + +Returns the new order instance. The object returned is not +saved. + +C<%params> can include the following options +(C is mandatory): + +=over 4 + +=item C + +(mandatory) +The type of the newly created object. Can be C, +C, C or C for now. + +=item C + +An optional array reference of RDBO instances for the items to use. If +missing then the method C will be called on +C<$source>. This option can be used to override the sorting, to +exclude certain positions or to add additional ones. + +=item C + +If trueish then items with a negative quantity are skipped. Items with +a quantity of 0 are not affected by this option. + +=item C + +If trueish then items with a quantity of 0 are skipped. + +=item C + +An optional code reference that is called for each item with the item +as its sole parameter. Items for which the code reference returns a +falsish value will be skipped. + +=item C + +An optional hash reference. If it exists then it is passed to C +allowing the caller to set certain attributes for the new delivery +order. + +=back + +=head2 C + +Creates a new C instance from multiple sources and copies as +much information from C<$sources> as possible. +At the moment only sales orders can be combined and they must be of the same +customer. + +The new order is created from the first one using C and the positions +of all orders are added to the new order. The orders can be sorted with the +parameter C. -Creates and saves a new sales process. Can only be called for sales -orders. +The orders attributes are kept if they contain the same information for all +source orders an will be set to empty if they contain different information. -The newly created process will be linked bidirectionally to both -C<$self> and to all sales quotations that are linked to C<$self>. +Returns the new order instance. The object returned is not +saved. -Returns the newly created process instance. +C other then C are passed to C. =head1 BUGS