6 use List::MoreUtils qw(any uniq);
7 use List::Util qw(sum);
8 use Rose::DB::Object::Helpers qw(as_tree);
10 use SL::Locale::String qw(t8);
11 use SL::Helper::Inventory;
13 use SL::DB::MetaSetup::Part;
14 use SL::DB::Manager::Part;
15 use SL::DB::Helper::AttrHTML;
16 use SL::DB::Helper::AttrSorted;
17 use SL::DB::Helper::TransNumberGenerator;
18 use SL::DB::Helper::CustomVariables (
22 use SL::DB::Helper::DisplayableNamePreferences (
23 title => t8('Article'),
24 options => [ {name => 'partnumber', title => t8('Part Number') },
25 {name => 'description', title => t8('Description') },
26 {name => 'notes', title => t8('Notes')},
27 {name => 'ean', title => t8('EAN') }, ],
31 __PACKAGE__->meta->add_relationships(
33 type => 'one to many',
34 class => 'SL::DB::Assembly',
35 manager_args => { sort_by => 'position' },
36 column_map => { id => 'id' },
39 type => 'one to many',
40 class => 'SL::DB::Price',
41 column_map => { id => 'parts_id' },
42 manager_args => { with_objects => [ 'pricegroup' ] }
45 type => 'one to many',
46 class => 'SL::DB::MakeModel',
47 manager_args => { sort_by => 'sortorder' },
48 column_map => { id => 'parts_id' },
51 type => 'one to many',
52 class => 'SL::DB::BusinessModel',
53 column_map => { id => 'parts_id' },
56 type => 'one to many',
57 class => 'SL::DB::PartCustomerPrice',
58 column_map => { id => 'parts_id' },
61 type => 'one to many',
62 class => 'SL::DB::Translation',
63 column_map => { id => 'parts_id' },
66 type => 'one to many',
67 class => 'SL::DB::AssortmentItem',
68 column_map => { id => 'assortment_id' },
69 manager_args => { sort_by => 'position' },
72 type => 'one to many',
73 class => 'SL::DB::History',
74 column_map => { id => 'trans_id' },
75 query_args => [ what_done => 'part' ],
76 manager_args => { sort_by => 'itime' },
79 type => 'one to many',
80 class => 'SL::DB::ShopPart',
81 column_map => { id => 'part_id' },
82 manager_args => { with_objects => [ 'shop' ] },
84 last_price_update => {
86 class => 'SL::DB::PartsPriceHistory',
87 column_map => { id => 'part_id' },
88 manager_args => { sort_by => 'valid_from DESC, id DESC', limit => 1 },
90 purchase_basket_item => {
92 class => 'SL::DB::PurchaseBasketItem',
93 column_map => { id => 'part_id' },
97 __PACKAGE__->meta->initialize;
99 use Rose::Object::MakeMethods::Generic (
100 'scalar --get_set_init' => [ qw(onhandqty stockqty get_open_ordered_qty) ],
102 __PACKAGE__->attr_html('notes');
103 __PACKAGE__->attr_sorted({ unsorted => 'makemodels', position => 'sortorder' });
104 __PACKAGE__->attr_sorted({ unsorted => 'customerprices', position => 'sortorder' });
105 __PACKAGE__->attr_sorted('businessmodels');
107 __PACKAGE__->before_save('_before_save_set_partnumber');
108 __PACKAGE__->before_save('_before_save_set_assembly_weight');
110 sub _before_save_set_partnumber {
113 $self->create_trans_number if !$self->partnumber;
117 sub _before_save_set_assembly_weight {
120 if ( $self->part_type eq 'assembly' ) {
121 my $weight_sum = $self->items_weight_sum;
122 $self->weight($self->items_weight_sum) if $weight_sum;
130 if ( $self->part_type eq 'assembly' ) {
131 return $self->assemblies;
132 } elsif ( $self->part_type eq 'assortment' ) {
133 return $self->assortment_items;
142 # for detecting if the items of an (orphaned) assembly or assortment have
143 # changed when saving
145 return join(' ', sort map { $_->part->id } @{$self->items});
152 push @errors, $::locale->text('The partnumber is missing.') if $self->id and !$self->partnumber;
153 push @errors, $::locale->text('The unit is missing.') unless $self->unit;
154 push @errors, $::locale->text('The buchungsgruppe is missing.') unless $self->buchungsgruppen_id or $self->buchungsgruppe;
156 if ( $::instance_conf->get_partsgroup_required
157 && ( !$self->partsgroup_id or ( $self->id && !$self->partsgroup_id && $self->partsgroup ) ) ) {
158 # when unsetting an existing partsgroup in the interface, $self->partsgroup_id will be undef but $self->partsgroup will still have a value
159 # this needs to be checked, as partsgroup dropdown has an empty value
160 push @errors, $::locale->text('The partsgroup is missing.');
163 unless ( $self->id ) {
164 push @errors, $::locale->text('The partnumber already exists.') if SL::DB::Manager::Part->get_all_count(where => [ partnumber => $self->partnumber ]);
167 if ($self->is_assortment && $self->orphaned && scalar @{$self->assortment_items} == 0) {
168 # when assortment isn't orphaned form doesn't contain any items
169 push @errors, $::locale->text('The assortment doesn\'t have any items.');
172 if ($self->is_assembly && scalar @{$self->assemblies} == 0) {
173 push @errors, $::locale->text('The assembly doesn\'t have any items.');
181 my $type = lc(shift || '');
182 die 'invalid type' unless $type =~ /^(?:part|service|assembly|assortment)$/;
184 return $self->type eq $type ? 1 : 0;
187 sub is_part { $_[0]->part_type eq 'part' }
188 sub is_assembly { $_[0]->part_type eq 'assembly' }
189 sub is_service { $_[0]->part_type eq 'service' }
190 sub is_assortment { $_[0]->part_type eq 'assortment' }
193 return $_[0]->part_type;
194 # my ($self, $type) = @_;
196 # die 'invalid type' unless $type =~ /^(?:part|service|assembly)$/;
197 # $self->assembly( $type eq 'assembly' ? 1 : 0);
198 # $self->inventory_accno_id($type ne 'service' ? 1 : undef);
201 # return 'assembly' if $self->assembly;
202 # return 'part' if $self->inventory_accno_id;
207 my ($class, %params) = @_;
208 $class->new(%params, part_type => 'part');
212 my ($class, %params) = @_;
213 $class->new(%params, part_type => 'assembly');
217 my ($class, %params) = @_;
218 $class->new(%params, part_type => 'service');
222 my ($class, %params) = @_;
223 $class->new(%params, part_type => 'assortment');
226 sub last_modification {
228 return $self->mtime // $self->itime;
233 die 'not an accessor' if @_ > 1;
235 return 1 unless $self->id;
240 SL::DB::DeliveryOrderItem
243 for my $class (@relations) {
244 eval "require $class";
245 return 1 if $class->_get_manager_class->get_all_count(query => [ parts_id => $self->id ]);
252 die 'not an accessor' if @_ > 1;
254 return 1 unless $self->id;
259 SL::DB::DeliveryOrderItem
261 SL::DB::AssortmentItem
264 for my $class (@relations) {
265 eval "require $class";
266 return 0 if $class->_get_manager_class->get_all_count(query => [ parts_id => $self->id ]);
271 sub get_sellprice_info {
275 confess "Missing part id" unless $self->id;
277 my $object = $self->load;
279 return { sellprice => $object->sellprice,
280 price_factor_id => $object->price_factor_id };
283 sub get_ordered_qty {
285 my %result = SL::DB::Manager::Part->get_ordered_qty($self->id);
287 return $result{ $self->id };
290 sub available_units {
291 shift->unit_obj->convertible_units;
294 # autogenerated accessor is slightly off...
296 shift->buchungsgruppen(@_);
300 my ($self, %params) = @_;
302 my $date = $params{date} || DateTime->today_local;
303 my $is_sales = !!$params{is_sales};
304 my $taxzone = $params{ defined($params{taxzone}) ? 'taxzone' : 'taxzone_id' } * 1;
305 my $tk_info = $::request->cache('get_taxkey');
307 $tk_info->{$self->id} //= {};
308 $tk_info->{$self->id}->{$taxzone} //= { };
309 my $cache = $tk_info->{$self->id}->{$taxzone}->{$is_sales} //= { };
311 if (!exists $cache->{$date}) {
313 $self->get_chart(type => $is_sales ? 'income' : 'expense', taxzone => $taxzone)
314 ->get_active_taxkey($date);
317 return $cache->{$date};
321 my ($self, %params) = @_;
322 require SL::DB::Chart;
324 my $type = (any { $_ eq $params{type} } qw(income expense inventory)) ? $params{type} : croak("Invalid 'type' parameter '$params{type}'");
325 my $taxzone = $params{ defined($params{taxzone}) ? 'taxzone' : 'taxzone_id' } * 1;
327 my $charts = $::request->cache('get_chart_id/by_part_id_and_taxzone')->{$self->id} //= {};
328 my $all_charts = $::request->cache('get_chart_id/by_id');
330 $charts->{$taxzone} ||= { };
332 if (!exists $charts->{$taxzone}->{$type}) {
333 require SL::DB::Buchungsgruppe;
334 my $bugru = SL::DB::Buchungsgruppe->load_cached($self->buchungsgruppen_id);
335 my $chart_id = ($type eq 'inventory') ? ($self->is_part ? $bugru->inventory_accno_id : undef)
336 : $bugru->call_sub("${type}_accno_id", $taxzone);
339 my $chart = $all_charts->{$chart_id} // SL::DB::Chart->load_cached($chart_id)->load;
340 $all_charts->{$chart_id} = $chart;
341 $charts->{$taxzone}->{$type} = $chart;
345 return $charts->{$taxzone}->{$type};
349 my ($self, %params) = @_;
351 return undef unless $self->id;
353 my $query = 'SELECT SUM(qty) FROM inventory WHERE parts_id = ?';
354 my @values = ($self->id);
356 if ( $params{bin_id} ) {
357 $query .= ' AND bin_id = ?';
358 push(@values, $params{bin_id});
361 if ( $params{warehouse_id} ) {
362 $query .= ' AND warehouse_id = ?';
363 push(@values, $params{warehouse_id});
366 if ( $params{shippingdate} ) {
367 die unless ref($params{shippingdate}) eq 'DateTime';
368 $query .= ' AND shippingdate <= ?';
369 push(@values, $params{shippingdate});
372 my ($stock) = selectrow_query($::form, $self->db->dbh, $query, @values);
374 return $stock || 0; # never return undef
378 # this is designed to ignore chargenumbers, expiration dates and just give a list of how much <-> where
379 sub get_simple_stock {
380 my ($self, %params) = @_;
382 return [] unless $self->id;
385 SELECT sum(qty), warehouse_id, bin_id FROM inventory WHERE parts_id = ?
386 GROUP BY warehouse_id, bin_id
388 my $stock_info = selectall_hashref_query($::form, $::form->get_standard_dbh, $query, $self->id);
389 [ map { bless $_, 'SL::DB::Part::SimpleStock'} @$stock_info ];
391 # helper class to have bin/warehouse accessors in stock result
392 { package SL::DB::Part::SimpleStock;
393 sub warehouse { require SL::DB::Warehouse; SL::DB::Manager::Warehouse->find_by_or_create(id => $_[0]->{warehouse_id}) }
394 sub bin { require SL::DB::Bin; SL::DB::Manager::Bin ->find_by_or_create(id => $_[0]->{bin_id}) }
397 sub get_simple_stock_sql {
398 my ($self, %params) = @_;
400 return [] unless $self->id;
403 SELECT w.description AS warehouse_description,
404 b.description AS bin_description,
406 SUM(i.qty * p.lastcost) AS stock_value,
408 LEAD(w.description) OVER pt AS wh_lead, -- to detect warehouse changes for subtotals in template
409 SUM( SUM(i.qty) ) OVER pt AS run_qty, -- running total of total qty
410 SUM( SUM(i.qty) ) OVER wh AS wh_run_qty, -- running total of warehouse qty
411 SUM( SUM(i.qty * p.lastcost)) OVER pt AS run_stock_value, -- running total of total stock_value
412 SUM( SUM(i.qty * p.lastcost)) OVER wh AS wh_run_stock_value -- running total of warehouse stock_value
414 LEFT JOIN parts p ON (p.id = i.parts_id)
415 LEFT JOIN warehouse w ON (i.warehouse_id = w.id)
416 LEFT JOIN bin b ON (i.bin_id = b.id)
418 GROUP BY w.description, w.sortkey, b.description, p.unit, i.parts_id
420 WINDOW pt AS (PARTITION BY i.parts_id ORDER BY w.sortkey, b.description, p.unit),
421 wh AS (PARTITION by w.description ORDER BY w.sortkey, b.description, p.unit)
422 ORDER BY w.sortkey, b.description, p.unit
425 my $stock_info = selectall_hashref_query($::form, $self->db->dbh, $query, $self->id);
429 sub get_mini_journal {
432 # inventory ids of the most recent 10 inventory trans_ids
434 # duplicate code copied from SL::Controller::Inventory mini_journal, except
435 # for the added filter on parts_id
437 my $parts_id = $self->id;
439 with last_inventories as (
444 where parts_id = $parts_id
451 from last_inventories
458 limit 20 -- so the planner knows how many ids to expect, the cte is an optimisation fence
461 my $objs = SL::DB::Manager::Inventory->get_all(
462 query => [ id => [ \"$query" ] ], # make emacs happy "]]
463 with_objects => [ 'parts', 'trans_type', 'bin', 'bin.warehouse' ], # prevent lazy loading in template
464 sort_by => 'itime DESC',
466 # remember order of trans_ids from query, for ordering hash later
467 my @sorted_trans_ids = uniq map { $_->trans_id } @$objs;
469 # at most 2 of them belong to a transaction and the qty determines in or out.
472 $transactions{ $_->trans_id }{ $_->qty > 0 ? 'in' : 'out' } = $_;
473 $transactions{ $_->trans_id }{base} = $_;
476 # because the inventory transactions were built in a hash, we need to sort the
477 # hash by using the original sort order of the trans_ids
478 my @sorted = map { $transactions{$_} } @sorted_trans_ids;
483 sub clone_and_reset_deep {
486 my $clone = $self->clone_and_reset; # resets id and partnumber (primary key and unique constraint)
487 $clone->makemodels( map { $_->clone_and_reset } @{$self->makemodels} ) if @{$self->makemodels};
488 $clone->translations( map { $_->clone_and_reset } @{$self->translations} ) if @{$self->translations};
489 $clone->custom_variables( map { $_->clone_and_reset } @{$self->custom_variables} ) if @{$self->custom_variables};
490 if ( $self->is_assortment ) {
491 # use clone rather than reset_and_clone because the unique constraint would also remove parts_id
492 $clone->assortment_items( map { $_->clone } @{$self->assortment_items} );
493 $_->assortment_id(undef) foreach @{ $clone->assortment_items }
496 if ( $self->is_assembly ) {
497 $clone->assemblies( map { $_->clone_and_reset } @{$self->assemblies});
500 if ( $self->prices ) {
501 $clone->prices( map { $_->clone } @{$self->prices}); # pricegroup_id gets reset here because it is part of a unique contraint
502 if ( $clone->prices ) {
503 foreach my $price ( @{$clone->prices} ) {
505 $price->parts_id(undef);
514 my ($self, $comparison_part) = @_;
516 die "item_diffs needs a part object" unless ref($comparison_part) eq 'SL::DB::Part';
517 die "part and comparison_part need to be of the same part_type" unless
518 ( $self->part_type eq 'assembly' or $self->part_type eq 'assortment' )
519 and ( $comparison_part->part_type eq 'assembly' or $comparison_part->part_type eq 'assortment' )
520 and $self->part_type eq $comparison_part->part_type;
522 # return [], [] if $self->items_checksum eq $comparison_part->items_checksum;
523 my @self_part_ids = map { $_->parts_id } $self->items;
524 my @comparison_part_ids = map { $_->parts_id } $comparison_part->items;
526 my %orig = map{ $_ => 1 } @self_part_ids;
527 my %comparison = map{ $_ => 1 } @comparison_part_ids;
528 my (@additions, @removals);
529 @additions = grep { !exists( $orig{$_} ) } @comparison_part_ids if @comparison_part_ids;
530 @removals = grep { !exists( $comparison{$_} ) } @self_part_ids if @self_part_ids;
532 return \@additions, \@removals;
535 sub items_sellprice_sum {
536 my ($self, %params) = @_;
538 return unless $self->is_assortment or $self->is_assembly;
539 return unless $self->items;
541 if ($self->is_assembly) {
542 return sum map { $_->linetotal_sellprice } @{$self->items};
544 return sum map { $_->linetotal_sellprice(%params) } grep { $_->charge } @{$self->items};
548 sub items_lastcost_sum {
551 return unless $self->is_assortment or $self->is_assembly;
552 return unless $self->items;
553 sum map { $_->linetotal_lastcost } @{$self->items};
556 sub items_weight_sum {
559 return unless $self->is_assembly;
560 return unless $self->items;
561 sum map { $_->linetotal_weight} @{$self->items};
564 sub set_lastcost_assemblies_and_assortiments {
567 return 1 unless $self->id; # not saved yet
569 require SL::DB::AssortmentItem;
570 require SL::DB::Assembly;
573 my $assortments = SL::DB::Manager::AssortmentItem->get_all(where => [parts_id => $self->id ]);
574 my $assemblies = SL::DB::Manager::Assembly->get_all( where => [parts_id => $self->id ]);
576 foreach my $assembly (@{ $assemblies }) {
577 my $a = $assembly->assembly_part;
578 $a->update_attributes(lastcost => $a->items_lastcost_sum);
579 $a->set_lastcost_assemblies_and_assortiments;
581 foreach my $assortment (@{ $assortments }) {
582 my $a = $assortment->assortment;
583 $a->update_attributes(lastcost => $a->items_lastcost_sum);
584 $a->set_lastcost_assemblies_and_assortiments;
591 my $qty = SL::Helper::Inventory::get_onhand(part => $self->id) || 0;
597 my $qty = SL::Helper::Inventory::get_stock(part => $self->id) || 0;
601 sub init_get_open_ordered_qty {
603 my $result = SL::DB::Manager::Part->get_open_ordered_qty($self->id);
618 SL::DB::Part: Model for the 'parts' table
622 This is a standard Rose::DB::Object based model and can be used as one.
626 Although the base class is called C<Part> we usually talk about C<Articles> if
627 we mean instances of this class. This is because articles come in three
632 =item Part - a single part
634 =item Service - a part without onhand, and without inventory accounting
636 =item Assembly - a collection of both parts and services
638 =item Assortment - a collection of items (parts or assemblies)
642 These types are sadly represented by data inside the class and cannot be
643 migrated into a flag. To work around this, each C<Part> object knows what type
644 it currently is. Since the type is data driven, there ist no explicit setting
645 method for it, but you can construct them explicitly with C<new_part>,
646 C<new_service>, C<new_assembly> and C<new_assortment>. A Buchungsgruppe should be supplied in this
647 case, but it will use the default Buchungsgruppe if you don't.
649 Matching these there are assorted helper methods dealing with types,
650 e.g. L</new_part>, L</new_service>, L</new_assembly>, L</type>,
651 L</is_type> and others.
657 =item C<new_part %PARAMS>
659 =item C<new_service %PARAMS>
661 =item C<new_assembly %PARAMS>
663 Will set the appropriate data fields so that the resulting instance will be of
664 the requested type. Since accounting targets are part of the distinction,
665 providing a C<Buchungsgruppe> is recommended. If none is given the constructor
666 will load a default one and set the accounting targets from it.
670 Returns the type as a string. Can be one of C<part>, C<service>, C<assembly>.
672 =item C<is_type $TYPE>
674 Tests if the current object is a part, a service or an
675 assembly. C<$type> must be one of the words 'part', 'service' or
676 'assembly' (their plurals are ok, too).
678 Returns 1 if the requested type matches, 0 if it doesn't and
679 C<confess>es if an unknown C<$type> parameter is encountered.
687 Shorthand for C<is_type('part')> etc.
689 =item C<get_sellprice_info %params>
691 Retrieves the C<sellprice> and C<price_factor_id> for a part under
692 different conditions and returns a hash reference with those two keys.
694 If C<%params> contains a key C<project_id> then a project price list
695 will be consulted if one exists for that project. In this case the
696 parameter C<country_id> is evaluated as well: if a price list entry
697 has been created for this country then it will be used. Otherwise an
698 entry without a country set will be used.
700 If none of the above conditions is met then the information from
703 =item C<get_ordered_qty %params>
705 Retrieves the quantity that has been ordered from a vendor but that
706 has not been delivered yet. Only open purchase orders are considered.
708 =item C<get_taxkey %params>
710 Retrieves and returns a taxkey object valid for the given date
711 C<$params{date}> and tax zone C<$params{taxzone}>
712 (C<$params{taxzone_id}> is also recognized). The date defaults to the
713 current date if undefined.
715 This function looks up the income (for trueish values of
716 C<$params{is_sales}>) or expense (for falsish values of
717 C<$params{is_sales}>) account for the current part. It uses the part's
718 associated buchungsgruppe and uses the fields belonging to the tax
719 zone given by C<$params{taxzone}>.
721 The information retrieved by the function is cached.
723 =item C<get_chart %params>
725 Retrieves and returns a chart object valid for the given type
726 C<$params{type}> and tax zone C<$params{taxzone}>
727 (C<$params{taxzone_id}> is also recognized). The type must be one of
728 the three key words C<income>, C<expense> and C<inventory>.
730 This function uses the part's associated buchungsgruppe and uses the
731 fields belonging to the tax zone given by C<$params{taxzone}>.
733 The information retrieved by the function is cached.
735 =item C<used_in_record>
737 Checks if this article has been used in orders, invoices or delivery orders.
741 Checks if this article is used in orders, invoices, delivery orders or
744 =item C<buchungsgruppe BUCHUNGSGRUPPE>
746 Used to set the accounting information from a L<SL:DB::Buchungsgruppe> object.
747 Please note, that this is a write only accessor, the original Buchungsgruppe can
748 not be retrieved from an article once set.
750 =item C<get_simple_stock_sql>
752 Fetches the qty and the stock value for the current part for each bin and
753 warehouse where the part is in stock (or rather different from 0, might be
756 Runs some additional window functions to add the running totals (total running
757 total and total per warehouse) for qty and stock value to each line.
759 Using the LEAD(w.description) the template can check if the warehouse
760 description is about to change, i.e. the next line will contain numbers from a
761 different warehouse, so that a subtotal line can be added.
763 The last row will contain the running qty total (run_qty) and the running total
764 stock value (run_stock_value) over all warehouses/bins and can be used to add a
765 line for the grand totals.
767 =item C<items_lastcost_sum>
769 Non-recursive lastcost sum of all the items in an assembly or assortment.
771 =item C<get_stock %params>
773 Fetches stock qty in the default unit for a part.
775 bin_id and warehouse_id may be passed as params. If only a bin_id is passed,
776 the stock qty for that bin is returned. If only a warehouse_id is passed, the
777 stock qty for all bins in that warehouse is returned. If a shippingdate is
778 passed the stock qty for that date is returned.
781 my $qty = $part->get_stock(bin_id => 52);
783 $part->get_stock(shippingdate => DateTime->today->add(days => -5));
789 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>,
790 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>