6 use List::MoreUtils qw(any uniq);
7 use Rose::DB::Object::Helpers qw(as_tree);
9 use SL::Locale::String qw(t8);
11 use SL::DB::MetaSetup::Part;
12 use SL::DB::Manager::Part;
14 use SL::DB::Helper::AttrHTML;
15 use SL::DB::Helper::AttrSorted;
16 use SL::DB::Helper::TransNumberGenerator;
17 use SL::DB::Helper::CustomVariables (
21 use SL::DB::Helper::DisplayableNamePreferences (
22 title => t8('Article'),
23 options => [ {name => 'partnumber', title => t8('Part Number') },
24 {name => 'description', title => t8('Description') },
25 {name => 'notes', title => t8('Notes')},
26 {name => 'ean', title => t8('EAN') }, ],
29 use List::Util qw(sum);
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::PartCustomerPrice',
53 column_map => { id => 'parts_id' },
56 type => 'one to many',
57 class => 'SL::DB::Translation',
58 column_map => { id => 'parts_id' },
61 type => 'one to many',
62 class => 'SL::DB::AssortmentItem',
63 column_map => { id => 'assortment_id' },
64 manager_args => { sort_by => 'position' },
67 type => 'one to many',
68 class => 'SL::DB::History',
69 column_map => { id => 'trans_id' },
70 query_args => [ what_done => 'part' ],
71 manager_args => { sort_by => 'itime' },
74 type => 'one to many',
75 class => 'SL::DB::ShopPart',
76 column_map => { id => 'part_id' },
77 manager_args => { with_objects => [ 'shop' ] },
79 last_price_update => {
81 class => 'SL::DB::PartsPriceHistory',
82 column_map => { id => 'part_id' },
83 manager_args => { sort_by => 'valid_from DESC', limit => 1 },
87 __PACKAGE__->meta->initialize;
89 __PACKAGE__->attr_html('notes');
90 __PACKAGE__->attr_sorted({ unsorted => 'makemodels', position => 'sortorder' });
91 __PACKAGE__->attr_sorted({ unsorted => 'customerprices', position => 'sortorder' });
93 __PACKAGE__->before_save('_before_save_set_partnumber');
95 sub _before_save_set_partnumber {
98 $self->create_trans_number if !$self->partnumber;
105 if ( $self->part_type eq 'assembly' ) {
106 return $self->assemblies;
107 } elsif ( $self->part_type eq 'assortment' ) {
108 return $self->assortment_items;
117 # for detecting if the items of an (orphaned) assembly or assortment have
118 # changed when saving
120 return join(' ', sort map { $_->part->id } @{$self->items});
127 push @errors, $::locale->text('The partnumber is missing.') if $self->id and !$self->partnumber;
128 push @errors, $::locale->text('The unit is missing.') unless $self->unit;
129 push @errors, $::locale->text('The buchungsgruppe is missing.') unless $self->buchungsgruppen_id or $self->buchungsgruppe;
131 unless ( $self->id ) {
132 push @errors, $::locale->text('The partnumber already exists.') if SL::DB::Manager::Part->get_all_count(where => [ partnumber => $self->partnumber ]);
135 if ($self->is_assortment && $self->orphaned && scalar @{$self->assortment_items} == 0) {
136 # when assortment isn't orphaned form doesn't contain any items
137 push @errors, $::locale->text('The assortment doesn\'t have any items.');
140 if ($self->is_assembly && scalar @{$self->assemblies} == 0) {
141 push @errors, $::locale->text('The assembly doesn\'t have any items.');
149 my $type = lc(shift || '');
150 die 'invalid type' unless $type =~ /^(?:part|service|assembly|assortment)$/;
152 return $self->type eq $type ? 1 : 0;
155 sub is_part { $_[0]->part_type eq 'part' }
156 sub is_assembly { $_[0]->part_type eq 'assembly' }
157 sub is_service { $_[0]->part_type eq 'service' }
158 sub is_assortment { $_[0]->part_type eq 'assortment' }
161 return $_[0]->part_type;
162 # my ($self, $type) = @_;
164 # die 'invalid type' unless $type =~ /^(?:part|service|assembly)$/;
165 # $self->assembly( $type eq 'assembly' ? 1 : 0);
166 # $self->inventory_accno_id($type ne 'service' ? 1 : undef);
169 # return 'assembly' if $self->assembly;
170 # return 'part' if $self->inventory_accno_id;
175 my ($class, %params) = @_;
176 $class->new(%params, part_type => 'part');
180 my ($class, %params) = @_;
181 $class->new(%params, part_type => 'assembly');
185 my ($class, %params) = @_;
186 $class->new(%params, part_type => 'service');
190 my ($class, %params) = @_;
191 $class->new(%params, part_type => 'assortment');
194 sub last_modification {
196 return $self->mtime // $self->itime;
201 die 'not an accessor' if @_ > 1;
203 return 1 unless $self->id;
208 SL::DB::DeliveryOrderItem
211 for my $class (@relations) {
212 eval "require $class";
213 return 1 if $class->_get_manager_class->get_all_count(query => [ parts_id => $self->id ]);
219 die 'not an accessor' if @_ > 1;
221 return 1 unless $self->id;
226 SL::DB::DeliveryOrderItem
228 SL::DB::AssortmentItem
231 for my $class (@relations) {
232 eval "require $class";
233 return 0 if $class->_get_manager_class->get_all_count(query => [ parts_id => $self->id ]);
238 sub get_sellprice_info {
242 confess "Missing part id" unless $self->id;
244 my $object = $self->load;
246 return { sellprice => $object->sellprice,
247 price_factor_id => $object->price_factor_id };
250 sub get_ordered_qty {
252 my %result = SL::DB::Manager::Part->get_ordered_qty($self->id);
254 return $result{ $self->id };
257 sub available_units {
258 shift->unit_obj->convertible_units;
261 # autogenerated accessor is slightly off...
263 shift->buchungsgruppen(@_);
267 my ($self, %params) = @_;
269 my $date = $params{date} || DateTime->today_local;
270 my $is_sales = !!$params{is_sales};
271 my $taxzone = $params{ defined($params{taxzone}) ? 'taxzone' : 'taxzone_id' } * 1;
272 my $tk_info = $::request->cache('get_taxkey');
274 $tk_info->{$self->id} //= {};
275 $tk_info->{$self->id}->{$taxzone} //= { };
276 my $cache = $tk_info->{$self->id}->{$taxzone}->{$is_sales} //= { };
278 if (!exists $cache->{$date}) {
280 $self->get_chart(type => $is_sales ? 'income' : 'expense', taxzone => $taxzone)
281 ->get_active_taxkey($date);
284 return $cache->{$date};
288 my ($self, %params) = @_;
290 my $type = (any { $_ eq $params{type} } qw(income expense inventory)) ? $params{type} : croak("Invalid 'type' parameter '$params{type}'");
291 my $taxzone = $params{ defined($params{taxzone}) ? 'taxzone' : 'taxzone_id' } * 1;
293 my $charts = $::request->cache('get_chart_id/by_part_id_and_taxzone')->{$self->id} //= {};
294 my $all_charts = $::request->cache('get_chart_id/by_id');
296 $charts->{$taxzone} ||= { };
298 if (!exists $charts->{$taxzone}->{$type}) {
299 require SL::DB::Buchungsgruppe;
300 my $bugru = SL::DB::Buchungsgruppe->load_cached($self->buchungsgruppen_id);
301 my $chart_id = ($type eq 'inventory') ? ($self->is_part ? $bugru->inventory_accno_id : undef)
302 : $bugru->call_sub("${type}_accno_id", $taxzone);
305 my $chart = $all_charts->{$chart_id} // SL::DB::Chart->load_cached($chart_id)->load;
306 $all_charts->{$chart_id} = $chart;
307 $charts->{$taxzone}->{$type} = $chart;
311 return $charts->{$taxzone}->{$type};
315 my ($self, %params) = @_;
317 return undef unless $self->id;
319 my $query = 'SELECT SUM(qty) FROM inventory WHERE parts_id = ?';
320 my @values = ($self->id);
322 if ( $params{bin_id} ) {
323 $query .= ' AND bin_id = ?';
324 push(@values, $params{bin_id});
327 if ( $params{warehouse_id} ) {
328 $query .= ' AND warehouse_id = ?';
329 push(@values, $params{warehouse_id});
332 if ( $params{shippingdate} ) {
333 die unless ref($params{shippingdate}) eq 'DateTime';
334 $query .= ' AND shippingdate <= ?';
335 push(@values, $params{shippingdate});
338 my ($stock) = selectrow_query($::form, $self->db->dbh, $query, @values);
340 return $stock || 0; # never return undef
344 # this is designed to ignore chargenumbers, expiration dates and just give a list of how much <-> where
345 sub get_simple_stock {
346 my ($self, %params) = @_;
348 return [] unless $self->id;
351 SELECT sum(qty), warehouse_id, bin_id FROM inventory WHERE parts_id = ?
352 GROUP BY warehouse_id, bin_id
354 my $stock_info = selectall_hashref_query($::form, $::form->get_standard_dbh, $query, $self->id);
355 [ map { bless $_, 'SL::DB::Part::SimpleStock'} @$stock_info ];
357 # helper class to have bin/warehouse accessors in stock result
358 { package SL::DB::Part::SimpleStock;
359 sub warehouse { require SL::DB::Warehouse; SL::DB::Manager::Warehouse->find_by_or_create(id => $_[0]->{warehouse_id}) }
360 sub bin { require SL::DB::Bin; SL::DB::Manager::Bin ->find_by_or_create(id => $_[0]->{bin_id}) }
363 sub get_simple_stock_sql {
364 my ($self, %params) = @_;
366 return [] unless $self->id;
369 SELECT w.description AS warehouse_description,
370 b.description AS bin_description,
372 SUM(i.qty * p.lastcost) AS stock_value,
374 LEAD(w.description) OVER pt AS wh_lead, -- to detect warehouse changes for subtotals in template
375 SUM( SUM(i.qty) ) OVER pt AS run_qty, -- running total of total qty
376 SUM( SUM(i.qty) ) OVER wh AS wh_run_qty, -- running total of warehouse qty
377 SUM( SUM(i.qty * p.lastcost)) OVER pt AS run_stock_value, -- running total of total stock_value
378 SUM( SUM(i.qty * p.lastcost)) OVER wh AS wh_run_stock_value -- running total of warehouse stock_value
380 LEFT JOIN parts p ON (p.id = i.parts_id)
381 LEFT JOIN warehouse w ON (i.warehouse_id = w.id)
382 LEFT JOIN bin b ON (i.bin_id = b.id)
384 GROUP BY w.description, w.sortkey, b.description, p.unit, i.parts_id
386 WINDOW pt AS (PARTITION BY i.parts_id ORDER BY w.sortkey, b.description, p.unit),
387 wh AS (PARTITION by w.description ORDER BY w.sortkey, b.description, p.unit)
388 ORDER BY w.sortkey, b.description, p.unit
391 my $stock_info = selectall_hashref_query($::form, $self->db->dbh, $query, $self->id);
395 sub get_mini_journal {
398 # inventory ids of the most recent 10 inventory trans_ids
400 # duplicate code copied from SL::Controller::Inventory mini_journal, except
401 # for the added filter on parts_id
403 my $parts_id = $self->id;
405 with last_inventories as (
410 where parts_id = $parts_id
417 from last_inventories
424 limit 20 -- so the planner knows how many ids to expect, the cte is an optimisation fence
427 my $objs = SL::DB::Manager::Inventory->get_all(
428 query => [ id => [ \"$query" ] ],
429 with_objects => [ 'parts', 'trans_type', 'bin', 'bin.warehouse' ], # prevent lazy loading in template
430 sort_by => 'itime DESC',
432 # remember order of trans_ids from query, for ordering hash later
433 my @sorted_trans_ids = uniq map { $_->trans_id } @$objs;
435 # at most 2 of them belong to a transaction and the qty determines in or out.
438 $transactions{ $_->trans_id }{ $_->qty > 0 ? 'in' : 'out' } = $_;
439 $transactions{ $_->trans_id }{base} = $_;
442 # because the inventory transactions were built in a hash, we need to sort the
443 # hash by using the original sort order of the trans_ids
444 my @sorted = map { $transactions{$_} } @sorted_trans_ids;
449 sub clone_and_reset_deep {
452 my $clone = $self->clone_and_reset; # resets id and partnumber (primary key and unique constraint)
453 $clone->makemodels( map { $_->clone_and_reset } @{$self->makemodels} ) if @{$self->makemodels};
454 $clone->translations( map { $_->clone_and_reset } @{$self->translations} ) if @{$self->translations};
456 if ( $self->is_assortment ) {
457 # use clone rather than reset_and_clone because the unique constraint would also remove parts_id
458 $clone->assortment_items( map { $_->clone } @{$self->assortment_items} );
459 $_->assortment_id(undef) foreach @{ $clone->assortment_items }
462 if ( $self->is_assembly ) {
463 $clone->assemblies( map { $_->clone_and_reset } @{$self->assemblies});
466 if ( $self->prices ) {
467 $clone->prices( map { $_->clone } @{$self->prices}); # pricegroup_id gets reset here because it is part of a unique contraint
468 if ( $clone->prices ) {
469 foreach my $price ( @{$clone->prices} ) {
471 $price->parts_id(undef);
480 my ($self, $comparison_part) = @_;
482 die "item_diffs needs a part object" unless ref($comparison_part) eq 'SL::DB::Part';
483 die "part and comparison_part need to be of the same part_type" unless
484 ( $self->part_type eq 'assembly' or $self->part_type eq 'assortment' )
485 and ( $comparison_part->part_type eq 'assembly' or $comparison_part->part_type eq 'assortment' )
486 and $self->part_type eq $comparison_part->part_type;
488 # return [], [] if $self->items_checksum eq $comparison_part->items_checksum;
489 my @self_part_ids = map { $_->parts_id } $self->items;
490 my @comparison_part_ids = map { $_->parts_id } $comparison_part->items;
492 my %orig = map{ $_ => 1 } @self_part_ids;
493 my %comparison = map{ $_ => 1 } @comparison_part_ids;
494 my (@additions, @removals);
495 @additions = grep { !exists( $orig{$_} ) } @comparison_part_ids if @comparison_part_ids;
496 @removals = grep { !exists( $comparison{$_} ) } @self_part_ids if @self_part_ids;
498 return \@additions, \@removals;
501 sub items_sellprice_sum {
502 my ($self, %params) = @_;
504 return unless $self->is_assortment or $self->is_assembly;
505 return unless $self->items;
507 if ($self->is_assembly) {
508 return sum map { $_->linetotal_sellprice } @{$self->items};
510 return sum map { $_->linetotal_sellprice(%params) } grep { $_->charge } @{$self->items};
514 sub items_lastcost_sum {
517 return unless $self->is_assortment or $self->is_assembly;
518 return unless $self->items;
519 sum map { $_->linetotal_lastcost } @{$self->items};
532 SL::DB::Part: Model for the 'parts' table
536 This is a standard Rose::DB::Object based model and can be used as one.
540 Although the base class is called C<Part> we usually talk about C<Articles> if
541 we mean instances of this class. This is because articles come in three
546 =item Part - a single part
548 =item Service - a part without onhand, and without inventory accounting
550 =item Assembly - a collection of both parts and services
552 =item Assortment - a collection of items (parts or assemblies)
556 These types are sadly represented by data inside the class and cannot be
557 migrated into a flag. To work around this, each C<Part> object knows what type
558 it currently is. Since the type is data driven, there ist no explicit setting
559 method for it, but you can construct them explicitly with C<new_part>,
560 C<new_service>, C<new_assembly> and C<new_assortment>. A Buchungsgruppe should be supplied in this
561 case, but it will use the default Buchungsgruppe if you don't.
563 Matching these there are assorted helper methods dealing with types,
564 e.g. L</new_part>, L</new_service>, L</new_assembly>, L</type>,
565 L</is_type> and others.
571 =item C<new_part %PARAMS>
573 =item C<new_service %PARAMS>
575 =item C<new_assembly %PARAMS>
577 Will set the appropriate data fields so that the resulting instance will be of
578 the requested type. Since accounting targets are part of the distinction,
579 providing a C<Buchungsgruppe> is recommended. If none is given the constructor
580 will load a default one and set the accounting targets from it.
584 Returns the type as a string. Can be one of C<part>, C<service>, C<assembly>.
586 =item C<is_type $TYPE>
588 Tests if the current object is a part, a service or an
589 assembly. C<$type> must be one of the words 'part', 'service' or
590 'assembly' (their plurals are ok, too).
592 Returns 1 if the requested type matches, 0 if it doesn't and
593 C<confess>es if an unknown C<$type> parameter is encountered.
601 Shorthand for C<is_type('part')> etc.
603 =item C<get_sellprice_info %params>
605 Retrieves the C<sellprice> and C<price_factor_id> for a part under
606 different conditions and returns a hash reference with those two keys.
608 If C<%params> contains a key C<project_id> then a project price list
609 will be consulted if one exists for that project. In this case the
610 parameter C<country_id> is evaluated as well: if a price list entry
611 has been created for this country then it will be used. Otherwise an
612 entry without a country set will be used.
614 If none of the above conditions is met then the information from
617 =item C<get_ordered_qty %params>
619 Retrieves the quantity that has been ordered from a vendor but that
620 has not been delivered yet. Only open purchase orders are considered.
622 =item C<get_taxkey %params>
624 Retrieves and returns a taxkey object valid for the given date
625 C<$params{date}> and tax zone C<$params{taxzone}>
626 (C<$params{taxzone_id}> is also recognized). The date defaults to the
627 current date if undefined.
629 This function looks up the income (for trueish values of
630 C<$params{is_sales}>) or expense (for falsish values of
631 C<$params{is_sales}>) account for the current part. It uses the part's
632 associated buchungsgruppe and uses the fields belonging to the tax
633 zone given by C<$params{taxzone}>.
635 The information retrieved by the function is cached.
637 =item C<get_chart %params>
639 Retrieves and returns a chart object valid for the given type
640 C<$params{type}> and tax zone C<$params{taxzone}>
641 (C<$params{taxzone_id}> is also recognized). The type must be one of
642 the three key words C<income>, C<expense> and C<inventory>.
644 This function uses the part's associated buchungsgruppe and uses the
645 fields belonging to the tax zone given by C<$params{taxzone}>.
647 The information retrieved by the function is cached.
649 =item C<used_in_record>
651 Checks if this article has been used in orders, invoices or delivery orders.
655 Checks if this article is used in orders, invoices, delivery orders or
658 =item C<buchungsgruppe BUCHUNGSGRUPPE>
660 Used to set the accounting information from a L<SL:DB::Buchungsgruppe> object.
661 Please note, that this is a write only accessor, the original Buchungsgruppe can
662 not be retrieved from an article once set.
664 =item C<get_simple_stock_sql>
666 Fetches the qty and the stock value for the current part for each bin and
667 warehouse where the part is in stock (or rather different from 0, might be
670 Runs some additional window functions to add the running totals (total running
671 total and total per warehouse) for qty and stock value to each line.
673 Using the LEAD(w.description) the template can check if the warehouse
674 description is about to change, i.e. the next line will contain numbers from a
675 different warehouse, so that a subtotal line can be added.
677 The last row will contain the running qty total (run_qty) and the running total
678 stock value (run_stock_value) over all warehouses/bins and can be used to add a
679 line for the grand totals.
681 =item C<items_lastcost_sum>
683 Non-recursive lastcost sum of all the items in an assembly or assortment.
685 =item C<get_stock %params>
687 Fetches stock qty in the default unit for a part.
689 bin_id and warehouse_id may be passed as params. If only a bin_id is passed,
690 the stock qty for that bin is returned. If only a warehouse_id is passed, the
691 stock qty for all bins in that warehouse is returned. If a shippingdate is
692 passed the stock qty for that date is returned.
695 my $qty = $part->get_stock(bin_id => 52);
697 $part->get_stock(shippingdate => DateTime->today->add(days => -5));
703 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>,
704 Sven Schöling E<lt>s.schoeling@linet-services.deE<gt>