File Coverage

blib/lib/Interchange6/Schema/Result/Product.pm
Criterion Covered Total %
statement 228 229 100.0
branch 118 118 100.0
condition 24 24 100.0
subroutine 27 28 100.0
pod 20 20 100.0
total 417 419 100.0


line stmt bran cond sub pod time code
1 2     2   1574 use utf8;
  2         5  
  2         12  
2              
3             package Interchange6::Schema::Result::Product;
4              
5             =head1 NAME
6              
7             Interchange6::Schema::Result::Product
8              
9             =cut
10              
11 2     2   95 use base 'Interchange6::Schema::Base::Attribute';
  2         5  
  2         298  
12              
13 2     2   19 use DateTime;
  2         4  
  2         42  
14 2     2   12 use Encode;
  2         4  
  2         193  
15 2     2   13 use Try::Tiny;
  2         6  
  2         182  
16              
17 2         17 use Interchange6::Schema::Candy -components => [
18             qw(
19             InflateColumn::DateTime
20             TimeStamp
21             Helper::Row::SelfResultSet
22             Helper::Row::ProxyResultSetMethod
23             Helper::Row::OnColumnChange
24             )
25 2     2   16 ];
  2         9  
26              
27             =head1 DESCRIPTION
28              
29             The products table contains three product types parent, child and single.
30              
31             =over
32              
33             =item *
34              
35             B<Parent Product> A parent product is a container product in which variations of parent product or "child products" are linked.
36              
37             =item *
38              
39             B<Child Product> A child product for example "Acme Pro 10lb Dumbbell" would include the canonical_sku of the parent item whose description might be something like "Acme Pro Dumbbell". In general a child product would contain attributes while a parent product would not.
40              
41             =item *
42              
43             B<Single Product> A single product does not have child products and will become a parent product if a child product exists.
44              
45             =back
46              
47             =cut
48              
49             =head1 ACCESSORS
50              
51             =head2 image
52              
53             This simple accessor is available to resultset searches which wish to add
54             column C<image> to stash an image in the result.
55              
56             =cut
57              
58             __PACKAGE__->mk_group_accessors( column => 'image' );
59              
60             =head2 sku
61              
62             SKU used by shop.
63              
64             Primary key.
65              
66             =cut
67              
68             primary_column sku => {
69             data_type => "varchar",
70             size => 64
71             };
72              
73             =head2 manufacturer_sku
74              
75             Manufacturer's sku.
76              
77             Is nullable.
78              
79             =cut
80              
81             column manufacturer_sku => {
82             data_type => "varchar",
83             size => 64,
84             is_nullable => 1,
85             };
86              
87             =head2 name
88              
89             The name used to identify the product.
90              
91             =cut
92              
93             column name => {
94             data_type => "varchar",
95             size => 255
96             };
97              
98             =head2 short_description
99              
100             A brief summary of the product.
101              
102             =cut
103              
104             column short_description => {
105             data_type => "varchar",
106             default_value => "",
107             size => 500
108             };
109              
110             =head2 description
111              
112             Full product description.
113              
114             =cut
115              
116             column description => {
117             data_type => "text"
118             };
119              
120             =head2 price
121              
122             Numeric value representing product cost.
123              
124             Defaults to 0.
125              
126             When C<price> is updated and product has related
127             L<Interchange6::Schema::Result::PriceModifier/discount> then also update
128             the related L<Interchange6::Schema::Result::PriceModifier/price>.
129             This is done using the method C<update_price_modifiers>.
130              
131             =cut
132              
133             # Max decimal places used by any currency as of 2015-12-01 is 3
134             #
135             # Note on amount of storage used by different backends for numeric/decimal:
136             #
137             # Pg: depends on the actual value being stored
138             # MySQL: 4 bytes for every 9 digits before and after the decimal point
139             # with different amount for 'leftover' digits.
140             # See: http://dev.mysql.com/doc/refman/5.1/en/precision-math-decimal-characteristics.html
141             # So 20,3 takes 8 bytes for lhs and 2 for rhs = 10 total
142             #
143             column price => {
144             data_type => "numeric",
145             size => [ 21, 3 ],
146             default_value => 0,
147             keep_storage_value => 1,
148             };
149              
150             before_column_change price => {
151             method => 'update_price_modifiers',
152             txn_wrap => 1,
153             };
154              
155             =head2 uri
156              
157             Unique product uri. Example "acme-pro-dumbbells". Is nullable.
158              
159             =cut
160              
161             unique_column uri => {
162             data_type => "varchar",
163             is_nullable => 1,
164             size => 255
165             };
166              
167             =head2 weight
168              
169             Numeric weight of the product. Defaults to zero.
170              
171             =cut
172              
173             column weight => {
174             data_type => "numeric",
175             size => [ 10, 2 ],
176             default_value => 0
177             };
178              
179             =head2 priority
180              
181             Display order priority.
182              
183             =cut
184              
185             column priority => {
186             data_type => "integer",
187             default_value => 0
188             };
189              
190             =head2 gtin
191              
192             Unique EAN or UPC type data. Is nullable.
193              
194             =cut
195              
196             unique_column gtin => {
197             data_type => "varchar",
198             is_nullable => 1,
199             size => 32
200             };
201              
202             =head2 canonical_sku
203              
204             The SKU of the main product if this product is a variant of a main product. Is nullable.
205              
206             =cut
207              
208             column canonical_sku => {
209             data_type => "varchar",
210             is_nullable => 1,
211             size => 64
212             };
213              
214             =head2 active
215              
216             Is this product active? Default is yes.
217              
218             =cut
219              
220             column active => {
221             data_type => "boolean",
222             default_value => 1
223             };
224              
225             =head2 inventory_exempt
226              
227             Is this product exempt from inventory? Default is no.
228              
229             =cut
230              
231             column inventory_exempt => {
232             data_type => "boolean",
233             default_value => 0
234             };
235              
236             =head2 combine
237              
238             Indicate whether products with the same SKU should be combined in the Cart.
239              
240             Defaults to true.
241              
242             =cut
243              
244             column combine => {
245             data_type => "boolean",
246             default_value => 1,
247             };
248              
249             =head2 created
250              
251             Date and time when this record was created returned as L<DateTime> object.
252             Value is auto-set on insert.
253              
254             =cut
255              
256             column created => {
257             data_type => "datetime",
258             set_on_create => 1
259             };
260              
261             =head2 last_modified
262              
263             Date and time when this record was last modified returned as L<DateTime> object.
264             Value is auto-set on insert and update.
265              
266             =cut
267              
268             column last_modified => {
269             data_type => "datetime",
270             set_on_create => 1,
271             set_on_update => 1
272             };
273              
274             =head1 RELATIONS
275              
276             =head2 canonical
277              
278             Type: belongs_to
279              
280             Related object: L<Interchange6::Schema::Result::Product>
281              
282             =cut
283              
284             belongs_to
285             canonical => "Interchange6::Schema::Result::Product",
286             { 'foreign.sku' => 'self.canonical_sku' },
287             { join_type => 'left' };
288              
289             =head2 variants
290              
291             Type: has_many
292              
293             Related object: L<Interchange6::Schema::Result::Product>
294              
295             =cut
296              
297             has_many
298             variants => "Interchange6::Schema::Result::Product",
299             { "foreign.canonical_sku" => "self.sku" },
300             { cascade_copy => 0, cascade_delete => 0 };
301              
302             =head2 cart_products
303              
304             Type: has_many
305              
306             Related object: L<Interchange6::Schema::Result::CartProduct>
307              
308             =cut
309              
310             has_many
311             cart_products => "Interchange6::Schema::Result::CartProduct",
312             "sku",
313             { cascade_copy => 0, cascade_delete => 0 };
314              
315             =head2 price_modifiers
316              
317             Type: has_many
318              
319             Related object: L<Interchange6::Schema::Result::PriceModifier>
320              
321             =cut
322              
323             has_many
324             price_modifiers => "Interchange6::Schema::Result::PriceModifier",
325             "sku";
326              
327             =head2 inventory
328              
329             Type: might_have
330              
331             Related object: L<Interchange6::Schema::Result::Inventory>
332              
333             =cut
334              
335             might_have
336             inventory => "Interchange6::Schema::Result::Inventory",
337             "sku",
338             { cascade_copy => 0, cascade_delete => 0 };
339              
340             =head2 media_products
341              
342             Type: has_many
343              
344             Related object: L<Interchange6::Schema::Result::MediaProduct>
345              
346             =cut
347              
348             has_many
349             media_products => "Interchange6::Schema::Result::MediaProduct",
350             "sku",
351             { cascade_copy => 0, cascade_delete => 0 };
352              
353             =head2 merchandising_products
354              
355             Type: has_many
356              
357             Related object: L<Interchange6::Schema::Result::MerchandisingProduct>
358              
359             =cut
360              
361             has_many
362             merchandising_products =>
363             "Interchange6::Schema::Result::MerchandisingProduct",
364             "sku",
365             { cascade_copy => 0, cascade_delete => 0 };
366              
367             =head2 merchandising_product_related
368              
369             Type: has_many
370              
371             Related object: L<Interchange6::Schema::Result::MerchandisingProduct>
372              
373             =cut
374              
375             has_many
376             merchandising_product_related =>
377             "Interchange6::Schema::Result::MerchandisingProduct",
378             { "foreign.sku_related" => "self.sku" },
379             { cascade_copy => 0, cascade_delete => 0 };
380              
381             =head2 navigation_products
382              
383             Type: has_many
384              
385             Related object: L<Interchange6::Schema::Result::NavigationProduct>
386              
387             =cut
388              
389             has_many
390             navigation_products => "Interchange6::Schema::Result::NavigationProduct",
391             "sku",
392             { cascade_copy => 0, cascade_delete => 0 };
393              
394             =head2 navigation
395              
396             Type: many_to_many with navigation
397              
398             =cut
399              
400             many_to_many navigations => "navigation_products", "navigation";
401              
402             =head2 orderlines
403              
404             Type: has_many
405              
406             Related object: L<Interchange6::Schema::Result::Orderline>
407              
408             =cut
409              
410             has_many
411             orderlines => "Interchange6::Schema::Result::Orderline",
412             "sku",
413             { cascade_copy => 0, cascade_delete => 0 };
414              
415             =head2 product_attributes
416              
417             Type: has_many
418              
419             Related object: L<Interchange6::Schema::Result::ProductAttribute>
420              
421             =cut
422              
423             has_many
424             product_attributes => "Interchange6::Schema::Result::ProductAttribute",
425             "sku",
426             { cascade_copy => 0, cascade_delete => 0 };
427              
428             =head2 media
429              
430             Type: many_to_many with media
431              
432             =cut
433              
434             many_to_many media => "media_products", "media";
435              
436             =head2 product_messages
437              
438             Type: has_many
439              
440             Related object: L<Interchange6::Schema::Result::ProductMessage>
441              
442             =cut
443              
444             has_many
445             product_messages => "Interchange6::Schema::Result::ProductMessage",
446             "sku", { cascade_copy => 0 };
447              
448             =head2 messages
449              
450             Type: many_to_many
451              
452             Accessor to related Message results.
453              
454             =cut
455              
456             many_to_many messages => "product_messages", "message";
457              
458             =head1 METHODS
459              
460             Attribute methods are provided by the L<Interchange6::Schema::Base::Attribute> class.
461              
462             =head2 insert
463              
464             Override inherited method to call L</generate_uri> method in case L</name>
465             and L</sku> have been supplied as arguments but L</uri> has not.
466              
467             =cut
468              
469             sub insert {
470 576     576 1 477749 my ( $self, @args ) = @_;
471 576 100       14058 $self->generate_uri unless $self->uri;
472 575         15486 $self->next::method(@args);
473 575         3016930 return $self;
474             }
475              
476             =head2 update_price_modifiers
477              
478             Called when L</price> is updated.
479              
480             =cut
481              
482             sub update_price_modifiers {
483 1     1 1 3640 my ( $self, $old_value, $new_value ) = @_;
484              
485 1         20 my $price_modifiers =
486             $self->price_modifiers->search( { discount => { '!=' => undef } } );
487              
488 1         691 while ( my $result = $price_modifiers->next ) {
489 1         3858 $result->update(
490             {
491             price => sprintf( "%.2f",
492             $new_value - ( $new_value * $result->discount / 100 ) )
493             }
494             );
495             }
496             }
497              
498             =head2 generate_uri($attrs)
499              
500             Called by L</new> if no uri is given as an argument.
501              
502             The following steps are taken:
503              
504             =over
505              
506             1. Join C<< $self->name >> and C<< $self->uri >> with C<-> and stash
507             in C<$uri> to allow manipulation via filters
508              
509             2. Remove leading and trailing spaces and replace remaining spaces and
510             C</> with C<->
511              
512             3. Search for all rows in L<Interchange6::Schema::Result::Setting> where
513             C<scope> is C<Product> and C<name> is <generate_uri_filter>
514              
515             4. For each row found eval C<< $row->value >>
516              
517             5. Finally set the value of column L</uri> to C<$uri>
518              
519             =back
520              
521             Filters stored in L<Interchange6::Schema::Result::Setting> are executed via
522             eval and have access to C<$uri> and also the product result held in
523             C<$self>
524              
525             Examples of filters stored in Setting might be:
526              
527             {
528             scope => 'Product',
529             name => 'generate_uri_filter',
530             value => '$uri =~ s/badstuff/goodstuff/gi',
531             },
532             {
533             scope => 'Product',
534             name => 'generate_uri_filter',
535             value => '$uri = lc($uri)',
536             },
537              
538             =cut
539              
540             sub generate_uri {
541 22     22 1 792 my $self = shift;
542              
543 22         448 my $uri = join("-", $self->name, $self->sku);
544              
545             # make sure we have clean utf8
546             try {
547 22 100   22   1223 $uri = Encode::decode( 'UTF-8', $uri, Encode::FB_CROAK )
548             unless utf8::is_utf8($uri);
549             }
550             catch {
551             # Haven't yet found a way to get here :)
552             # uncoverable subroutine
553             # uncoverable statement
554 0     0   0 $self->throw_exception(
555             "Product->generate_uri failed to decode UTF-8 text: $_" );
556 22         1121 };
557              
558 22         1370 $uri =~ s/^\s+//; # remove leading space
559 22         139 $uri =~ s/\s+$//; # remove trailing space
560 22         171 $uri =~ s{[\s/]+}{-}g; # change space and / to -
561              
562 22         99 my $filters = $self->result_source->schema->resultset('Setting')->search(
563             {
564             scope => 'Product',
565             name => 'generate_uri_filter',
566             },
567             );
568              
569 22         13131 while ( my $filter = $filters->next ) {
570 3         7822 eval $filter->value;
571 3 100       132 $self->throw_exception("Product->generate_uri filter croaked: $@")
572             if $@;
573             }
574              
575 21         47664 $self->uri($uri);
576             }
577              
578             =head2 path
579              
580             Produces navigation path for this product.
581             Returns array reference in scalar context.
582              
583             Uses $type to select specific taxonomy from navigation if present.
584              
585             =cut
586              
587             sub path {
588 5     5 1 21319 my ( $self, $type ) = @_;
589              
590 5         15 my $options = {};
591              
592 5 100       24 if ( defined $type ) {
593 1         6 $options = { "navigation.type" => $type };
594             }
595              
596             # search navigation entries for this product
597 5         92 my $navigation_product = $self->search_related(
598             'navigation_products',
599             $options,
600             {
601             prefetch => 'navigation',
602             order_by => {
603             -desc =>
604             [ 'me.priority', 'navigation.priority' ]
605             },
606             rows => 1,
607             }
608             )->single;
609              
610 5         67920 my @path;
611              
612 5 100       198 if ( defined $navigation_product ) {
613 4         82 my $nav = $navigation_product->navigation;
614 4         1042 my @anc = $nav->ancestors;
615              
616 4         20819 @path = ( @anc, $nav );
617             }
618              
619 5 100       52 return wantarray ? @path : \@path;
620             }
621              
622             #=head2 tier_pricing
623             #
624             #Tier pricing can be calculated for a single role and also a combination of several roles.
625             #
626             #=over 4
627             #
628             #=item Arguments: array reference of L<Role names|Interchange6::Schema::Result::Role/name>
629             #
630             #=item Return Value: in scalar context an array reference ordered by quantity ascending of hash references of quantity and price, in list context returns an array instead of array reference
631             #
632             #=back
633             #
634             #The method always returns the best price for specific price points including
635             #any PriceModifier rows where roles_id is undef.
636             #
637             # my $aref = $product->tier_pricing( 'trade' );
638             #
639             # # [
640             # # { quantity => 1, price => 20 },
641             # # { quantity => 10, price => 19 },
642             # # { quantity => 100, price => 18 },
643             # # ]
644             #
645             #=cut
646             #
647             ## TODO: SysPete is not happy with the initial version of this method.
648             ## Patches always welcome.
649             #
650             #sub tier_pricing {
651             # my ( $self, $args ) = @_;
652             #
653             # my $cond = { 'role.name' => undef };
654             #
655             # if ( $args ) {
656             # $self->throw_exception(
657             # "Argument to tier_pricing must be an array reference")
658             # unless ref($args) eq 'ARRAY';
659             #
660             # $cond = { 'role.name' => [ undef, { -in => $args } ] };
661             # }
662             #
663             # my @result = $self->price_modifiers->search(
664             # $cond,
665             # {
666             # join => 'role',
667             # select => [ 'quantity', { min => 'price' } ],
668             # as => [ 'quantity', 'price' ],
669             # group_by => 'quantity',
670             # order_by => { -asc => 'quantity' },
671             # result_class => 'DBIx::Class::ResultClass::HashRefInflator',
672             # },
673             # )->all;
674             #
675             # if ( scalar @result && $result[0]->{quantity} < 1 ) {
676             #
677             # # zero or minus qty should not be possible so we adjust to one if found
678             #
679             # $result[0]->{quantity} = 1;
680             # }
681             #
682             # # maybe no qty 1 tier is defined so make sure we've got one
683             #
684             # if ( scalar @result && $result[0]->{quantity} == 1 ) {
685             # $result[0]->{price} = $self->price
686             # if $self->price < $result[0]->{price};
687             # }
688             # else {
689             # unshift @result, +{ quantity => 1, price => $self->price };
690             # }
691             #
692             # # Remove quantities that are inappropriate due to price at higher
693             # # quantity being higher (or same as) that a price at a lower quantity.
694             # # Normally caused when there are different price breaks for different
695             # # roles but we have been asked to combine multiple roles.
696             #
697             # my @return;
698             # my $previous;
699             # foreach my $i ( @result ) {
700             # push @return, $i;
701             # unless ( defined $previous ) {
702             # $previous = $i->{price};
703             # next;
704             # }
705             # pop @return unless $i->{price} < $previous;
706             # }
707             #
708             # return wantarray ? @return : \@return;
709             #}
710              
711             =head2 selling_price
712              
713             Arguments should be given as a hash reference with the following keys/values:
714              
715             =over 4
716              
717             =item * quantity => $quantity
718              
719             C<quantity> defaults to 1 if not supplied.
720              
721             =back
722              
723             PriceModifier rows which have C<roles_id> undefined are always included in the
724             search in addition to any C<roles> that belonging to L<Schema/logger_in_user>.
725             This enables promotional prices to be specified between fixed dates in
726             L<Interchange6::Schema::Result::PriceModifier/price> to apply to all classes
727             of user whether logged in or not.
728              
729             Returns lowest price from L</price> and
730             L<Interchange6::Schema::Result::PriceModifier/price>.
731              
732             Throws exception on bad arguments though unexpected keys in the hash reference
733             will be silently discarded.
734              
735             If the query was constructed using
736             L<Interchange6::Schema::ResultSet::Product/with_lowest_selling_price> then
737             the cached value will be used rather than running a new query B<UNLESS>
738             arguments are supplied in which case a new query is performed.
739              
740             =cut
741              
742             sub selling_price {
743 176     176 1 402877 my ( $self, $args ) = @_;
744              
745 176         596 my $schema = $self->result_source->schema;
746              
747 176         5881 my $price = $self->price;
748              
749 176 100 100     2438 if ( $self->has_column_loaded('selling_price') && !defined $args ) {
750              
751             # initial query on Product already included selling_price so use it
752              
753 136         1852 return $self->get_column('selling_price');
754             }
755              
756 40 100       455 if ($args) {
757 32 100       115 $self->throw_exception(
758             "Argument to selling_price must be a hash reference")
759             unless ref($args) eq 'HASH';
760             }
761             else {
762 8         27 $args = {};
763             }
764              
765             # quantity
766              
767 39 100       155 if ( defined $args->{quantity} ) {
768             $self->throw_exception(
769             sprintf( "Bad quantity: %s", $args->{quantity} ) )
770 30 100       191 unless $args->{quantity} =~ /^\d+$/;
771             }
772             else {
773 9         39 $args->{quantity} = 1;
774             }
775              
776             # start building the the search condition
777              
778 38         210 my $today = $schema->format_datetime(DateTime->today);
779              
780             my $search_condition = {
781             quantity => { '<=', $args->{quantity} },
782 38         27728 start_date => [ undef, { '<=', $today } ],
783             end_date => [ undef, { '>=', $today } ],
784             roles_id => undef,
785             };
786              
787 38 100       1231 if ( my $user = $schema->current_user ) {
788              
789             # add roles_id condition
790              
791             $search_condition->{roles_id} = [
792             undef,
793             {
794 27         533 -in => $schema->resultset('UserRole')
795             ->search( { users_id => $user->id } )->get_column('roles_id')
796             ->as_query
797             }
798             ];
799             }
800              
801             # now finally we can see if there is a better price for this customer
802              
803 38         75183 my $selling_price =
804             $self->price_modifiers->search($search_condition)->get_column('price')
805             ->min;
806              
807             return
808 38 100 100     294418 defined $selling_price
809             && $selling_price < $price ? $selling_price : $price;
810             }
811              
812             =head2 highest_price
813              
814             If this is a canonical product without variants or a variant product then
815             this method will return undef. If highest price is the same as L</selling_price>
816             then we again return undef.
817              
818             If the query was constructed using
819             L<Interchange6::Schema::ResultSet::Product/with_highest_price> then
820             the cached value will be used rather than running a new query.
821              
822             This method calls L</variant_count> and L</selling_price> so when constructing
823             a resultset query consider also chaining the associated ResultSet methods.
824              
825             =cut
826              
827             sub highest_price {
828 138     138 1 2546877 my $self = shift;
829              
830 138 100       788 return undef unless $self->variant_count;
831              
832 14         66090 my $highest_price;
833              
834 14 100       83 if ( $self->has_column_loaded('highest_price') ) {
835 7         99 $highest_price = $self->get_column('highest_price');
836             }
837             else {
838 7         273 $highest_price = $self->variants->get_column('price')->max;
839             }
840              
841 14 100       43100 if ( $self->has_column_loaded('selling_price') ) {
842 7 100       77 return $highest_price if $highest_price > $self->selling_price;
843 1         55 return undef;
844             }
845              
846 7 100       516 if ( $highest_price >
847             $self->self_rs->with_lowest_selling_price->single->selling_price )
848             {
849 6         219 return $highest_price;
850             }
851              
852 1         18 return undef;
853             }
854              
855             =head2 find_variant \%input [\%match_info]
856              
857             Find product variant with the given attribute values
858             in $input.
859              
860             Returns variant in case of success.
861              
862             Returns undef in case of failure.
863              
864             You can pass an optional hash reference \%match_info
865             which is filled with attribute matches (only valid
866             in case of failure).
867              
868             =cut
869              
870             sub find_variant {
871 6     6 1 24355 my ( $self, $input, $match_info ) = @_;
872              
873 6 100       183 if ( $self->canonical_sku ) {
874 1         56 return $self->canonical->find_variant( $input, $match_info );
875             }
876              
877 5         153 my $gather_matches;
878              
879 5 100       24 if ( ref($match_info) eq 'HASH' ) {
880 2         7 $gather_matches = 1;
881             }
882              
883             # get all variants
884 5         24 my $all_variants = $self->search_related('variants');
885 5         3844 my $variant;
886              
887 5         26 while ( $variant = $all_variants->next ) {
888 19         57830 my $sku;
889              
890 19 100       64 if ($gather_matches) {
891 12         332 $sku = $variant->sku;
892             }
893              
894 19         307 my $variant_attributes = $variant->search_related(
895             'product_attributes',
896             {
897             'attribute.type' => 'variant',
898             },
899             {
900             join => 'attribute',
901             prefetch => 'attribute',
902             },
903             );
904              
905 19         34830 my %match;
906              
907 19         76 while ( my $prod_att = $variant_attributes->next ) {
908 34         214055 my $name = $prod_att->attribute->name;
909              
910 34         2908 my $pav_rs =
911             $prod_att->search_related( 'product_attribute_values', {},
912             { join => 'attribute_value', prefetch => 'attribute_value' } );
913              
914 34 100 100     54689 if ( $pav_rs->count != 1
      100        
915             || !defined $input->{$name}
916             || $pav_rs->next->attribute_value->value ne $input->{$name} )
917             {
918 27 100       328773 if ($gather_matches) {
919 22         96 $match_info->{$sku}->{$name} = 0;
920 22         93 next;
921             }
922             else {
923 5         24 last;
924             }
925             }
926              
927 7 100       113446 if ($gather_matches) {
928 2         107 $match_info->{$sku}->{$name} = 1;
929             }
930              
931 7         294 $match{$name} = 1;
932             }
933              
934 19 100       4368 if ( scalar( keys %$input ) == scalar( keys %match ) ) {
935 2         12 return $variant;
936             }
937             }
938              
939 3         856 return;
940             }
941              
942             =head2 attribute_iterator( %args )
943              
944             =over 4
945              
946             =item Arguments: C<< hashref => 1 >>
947              
948             =back
949              
950             Return a hashref of attributes keyed on attribute name instead of an arrayref.
951              
952             =over 4
953              
954             =item Arguments: C<< selected => $sku >>
955              
956             =back
957              
958             Set the 'selected' SKU. For a child product this is set automatically.
959              
960             =over 4
961              
962             =item Arguments: C<< cond => $cond >>
963              
964             =back
965              
966             Search condition to use. Default is:
967              
968             { 'attribute.type' => 'variant' }
969              
970             =over 4
971              
972             =item Arguments: C<< order_by => $order_by >>
973              
974             =back
975              
976             Ordering to use in query. Default is:
977              
978             [
979             { -desc => 'attribute.priority' },
980             { -asc => 'attribute.title' },
981             { -desc => 'attribute_value.priority' },
982             { -asc => 'attribute_value.title' },
983             ]
984              
985             Set the 'selected' SKU. For a child product this is set automatically.
986              
987             =over 4
988              
989             =item Returns: An arrayref of attributes complete with their respective attribute values.
990              
991             =back
992              
993             For canonical products, it shows all the attributes of the child products.
994              
995             For a child product, it shows all the attributes of the siblings.
996              
997             Example of returned arrayref:
998              
999             [
1000             {
1001             attribute_values => [
1002             {
1003             priority => 2,
1004             selected => 0,
1005             title => "Pink",
1006             value => "pink"
1007             },
1008             {
1009             priority => 1,
1010             selected => 0,
1011             title => "Yellow",
1012             value => "yellow"
1013             }
1014             ],
1015             name => "color",
1016             priority => 2,
1017             title => "Color"
1018             },
1019             {
1020             attribute_values => [
1021             {
1022             priority => 2,
1023             selected => 0,
1024             title => "Small",
1025             value => "small"
1026             },
1027             {
1028             priority => 1,
1029             selected => 0,
1030             title => "Medium",
1031             value => "medium"
1032             },
1033             ],
1034             name => "size",
1035             priority => 1,
1036             title => "Size"
1037             }
1038             ]
1039              
1040             =cut
1041              
1042             sub attribute_iterator {
1043 8     8 1 100797 my ( $self, %args ) = @_;
1044 8         22 my ($canonical);
1045              
1046 8 100       223 if ( $canonical = $self->canonical ) {
1047              
1048             # get canonical object
1049 1         6958 $args{selected} = $self->sku;
1050 1         26 return $canonical->attribute_iterator(%args);
1051             }
1052              
1053 7         3037 my $cond = {
1054             'attribute.type' => 'variant',
1055             };
1056              
1057 7 100       47 $cond = $args{cond} if defined $args{cond};
1058              
1059 7         45 my $order_by = [
1060             { -desc => 'attribute.priority' },
1061             { -asc => 'attribute.title' },
1062             { -desc => 'attribute_value.priority' },
1063             { -asc => 'attribute_value.title' },
1064             ];
1065              
1066 7 100       38 $order_by = $args{order_by} if defined $args{order_by};
1067              
1068             # search for variants
1069 7         37 my @prod_atts = $self->search_related('variants')->search_related(
1070             'product_attributes',
1071             $cond,
1072             {
1073             join => [
1074             'attribute', { product_attribute_values => 'attribute_value' },
1075             ],
1076             prefetch => [
1077             'attribute', { product_attribute_values => 'attribute_value' },
1078             ],
1079             order_by => $order_by,
1080             }
1081             )->hri->all;
1082              
1083 7         209363 my %attributes;
1084             my @ordered_names;
1085 7         1050 foreach my $prod_att ( @prod_atts ) {
1086 77         153 my $name = $prod_att->{attribute}->{name};
1087              
1088 77 100       190 unless ( exists $attributes{$name} ) {
1089 17         39 push @ordered_names, $name;
1090             $attributes{$name} = {
1091             name => $name,
1092             title => $prod_att->{attribute}->{title},
1093             priority => $prod_att->{attribute}->{priority},
1094 17         122 value_map => {},
1095             attribute_values => [],
1096             };
1097             }
1098              
1099 77         133 my $att_record = $attributes{$name};
1100              
1101 77         116 foreach my $prod_att_val ( @{ $prod_att->{product_attribute_values} } )
  77         141  
1102             {
1103             my %attr_value = (
1104             value => $prod_att_val->{attribute_value}->{value},
1105             title => $prod_att_val->{attribute_value}->{title},
1106             priority => $prod_att_val->{attribute_value}->{priority},
1107 77         267 selected => 0,
1108             );
1109              
1110 77 100       194 if ( !exists $att_record->{value_map}->{ $attr_value{value} } ) {
1111 35         101 $att_record->{value_map}->{ $attr_value{value} } = \%attr_value;
1112 35         67 push @{$attributes{$name}->{attribute_values}}, \%attr_value;
  35         75  
1113             }
1114              
1115             # determined whether this is the current attribute
1116 77 100 100     268 if ( $args{selected} && $prod_att->{sku} eq $args{selected} ) {
1117             $att_record->{value_map}->{ $attr_value{value} }->{selected} =
1118 4         15 1;
1119             }
1120             }
1121             }
1122              
1123 7         97 foreach my $key ( keys %attributes ) {
1124 17         62 delete $attributes{$key}->{value_map};
1125             }
1126              
1127 7 100       46 if ( $args{hashref} ) {
1128 5         176 return \%attributes;
1129             }
1130              
1131 2         9 return [ map { $attributes{$_} } @ordered_names ];
  6         109  
1132             }
1133              
1134             =head2 add_variants @variants
1135              
1136             Add variants from a list of hash references.
1137              
1138             Returns product object.
1139              
1140             Each hash reference contains attributes and column
1141             data which overrides data from the canonical product.
1142              
1143             The canonical sku of the variant is automatically set.
1144              
1145             Example for the hash reference (attributes in the first line):
1146              
1147             {color => 'yellow', size => 'small',
1148             sku => 'G0001-YELLOW-S',
1149             name => 'Six Small Yellow Tulips',
1150             uri => 'six-small-yellow-tulips'}
1151              
1152             Since there is a risk that attributes names might clash with Product column
1153             names (for example L</weight>) an improved syntax exists to prevent such
1154             problems. This is considered to be the preferred syntax:
1155              
1156             {
1157             sku => 'ROD00014-2-6-mid',
1158             uri => 'fishingrod-weight-2-length-6-flex-mid',
1159             price => 355,
1160             attributes => [
1161             { weight => '2' },
1162             { length => '6' },
1163             { action => 'mid' },
1164             ],
1165             }
1166              
1167             =cut
1168              
1169             sub add_variants {
1170 61     61 1 557124 my ( $self, @variants ) = @_;
1171 61         222 my %attr_map;
1172 61         321 my $attr_rs = $self->result_source->schema->resultset('Attribute');
1173              
1174 61         24505 for my $var_ref (@variants) {
1175 237         710739 my ( %attr, %product, $sku );
1176              
1177 237 100 100     2050 unless ( exists $var_ref->{sku} && ( $sku = $var_ref->{sku} ) ) {
1178 2         23 die "SKU missing in input for add_variants.";
1179             }
1180              
1181 235 100       893 if ( defined $var_ref->{attributes} ) {
1182              
1183             # new syntax with explicit attributes
1184              
1185 1         2 %attr = %{ delete $var_ref->{attributes} };
  1         8  
1186             }
1187              
1188             # weed out attribute values that might be mixed in with columns
1189             # as happens with old syntax
1190              
1191 235         1224 while ( my ( $name, $value ) = each %$var_ref ) {
1192 990 100       2632 if ( $self->result_source->has_column($name) ) {
1193 660         8952 $product{$name} = $value;
1194             }
1195             else {
1196 330         4297 $attr{$name} = $value;
1197             }
1198             }
1199              
1200 235         1272 while ( my ( $name, $value ) = each %attr ) {
1201              
1202 332         2600 my ( $attribute, $attribute_value );
1203              
1204 332 100       1028 if ( !$attr_map{$name} ) {
1205 76         596 my $set = $attr_rs->search(
1206             {
1207             name => $name,
1208             type => 'variant',
1209             }
1210             );
1211              
1212 76 100       17310 if ( !( $attribute = $set->next ) ) {
1213 1         2486 die "Missing variant attribute '$name' for SKU $sku";
1214             }
1215              
1216 75         192436 $attr_map{$name} = $attribute;
1217             }
1218              
1219             # search for attribute value
1220 331 100       12818 unless ( $attribute_value =
1221             $attr_map{$name}
1222             ->find_related( 'attribute_values', { value => $value } ) )
1223             {
1224 1         7893 die "Missing variant attribute value '$value'"
1225             . " for attribute '$name' and SKU $sku";
1226             }
1227              
1228 330         2128254 $attr{$name} = $attribute_value;
1229             }
1230              
1231             # clone with new values
1232 233         11424 $product{canonical_sku} = $self->sku;
1233              
1234 233         4870 $self->copy( \%product );
1235              
1236             # find or create product attribute and product attribute value
1237 233         829075 while ( my ( $name, $value ) = each %attr ) {
1238 330         391230 my $product_attribute = $attr_map{$name}
1239             ->find_or_create_related( 'product_attributes', { sku => $sku } );
1240              
1241 330         3371881 $product_attribute->create_related( 'product_attribute_values',
1242             { attribute_values_id => $value->id } );
1243             }
1244             }
1245              
1246 57         228598 return $self;
1247             }
1248              
1249             =head2 discount_percent
1250              
1251             If L</selling_price> is lower than L</price> returns the rounded percentage
1252             discount or undef.
1253              
1254             B<NOTE:> for parent products (products that have variants) this will always
1255             return undef.
1256              
1257             =cut
1258              
1259             sub discount_percent {
1260 3     3 1 2119 my $self = shift;
1261              
1262 3 100 100     21 if ( $self->variant_count || $self->selling_price == $self->price ) {
1263 2         9416 return undef;
1264             }
1265              
1266 1         64 return sprintf( "%.0f",
1267             ( $self->price - $self->selling_price ) / $self->price * 100 );
1268              
1269             }
1270              
1271             =head2 media_by_type
1272              
1273             Return a Media resultset with the related media, filtered by type
1274             (e.g. video or image). On the results you can call
1275             C<display_uri("type")> to get the actual uri.
1276              
1277             =cut
1278              
1279             sub media_by_type {
1280 5     5 1 84324 my ( $self, $typename ) = @_;
1281 5         15 my @media_out;
1282              
1283             # track back the schema and search the media type id
1284 5         25 my $type = $self->result_source->schema->resultset('MediaType')
1285             ->find( { type => $typename } );
1286 5 100       25035 return unless $type;
1287 4         94 return $self->media->search(
1288             {
1289             media_types_id => $type->media_types_id,
1290             },
1291             {
1292             order_by => 'uri',
1293             }
1294             );
1295             }
1296              
1297             =head2 product_reviews
1298              
1299             Reviews should only be associated with parent products.
1300              
1301             This method returns the related L<Interchange6::Schema::Result::ProductMessage>
1302             records for a parent product where the related
1303             L<Interchange6::Schema::Result::Message> has
1304             L<Interchange6::Schema::Result::MessageType/name> of C<product_review>.
1305             For a child product the ProductReview records for the parent are returned.
1306              
1307             =cut
1308              
1309             sub product_reviews {
1310 361     361 1 22892 my $self = shift;
1311              
1312 361 100       7437 $self = $self->canonical if $self->canonical_sku;
1313              
1314 361         19923 return $self->product_messages->search(
1315             {
1316             'message_type.name' => 'product_review',
1317             },
1318             {
1319             join => { message => 'message_type' },
1320             }
1321             );
1322             }
1323              
1324             =head2 reviews
1325              
1326             Reviews should only be associated with parent products. This method returns the related Message (reviews) records for a parent product. For a child product the Message records for the parent are returned.
1327              
1328             =over
1329              
1330             =item * Arguments: L<$cond|DBIx::Class::SQLMaker> | undef, L<\%attrs?|DBIx::Class::ResultSet#ATTRIBUTES>
1331              
1332             =back
1333              
1334             Arguments are passed as paremeters to search the related reviews.
1335              
1336             =cut
1337              
1338             sub reviews {
1339 14     14 1 174039 my $self = shift;
1340              
1341             # use parent if I have one
1342 14 100       442 $self = $self->canonical if $self->canonical_sku;
1343              
1344 14         450 return $self->product_reviews->search_related('message', @_);
1345             }
1346              
1347             =head2 top_reviews
1348              
1349             Returns the highest-rated approved public reviews for this product. Argument is max number of reviews to return which defaults to 5.
1350              
1351             =cut
1352              
1353             sub top_reviews {
1354 2     2 1 30677 my ( $self, $rows ) = @_;
1355 2 100       11 $rows = 5 unless defined $rows;
1356 2         16 return $self->reviews( { public => 1, approved => 1 },
1357             { rows => $rows, order_by => { -desc => 'rating' } } );
1358             }
1359              
1360             =head2 variant_count
1361              
1362             Returns the number of variants of this product.
1363              
1364             =cut
1365              
1366             proxy_resultset_method 'variant_count';
1367              
1368             =head2 has_variants
1369              
1370             Alias for L</variant_count> for backwards-compatibility.
1371              
1372             =cut
1373              
1374             sub has_variants {
1375 2     2 1 28163 return shift->variant_count;
1376             }
1377              
1378             =head2 average_rating
1379              
1380             Returns the average rating across all public and approved product reviews or undef if there are no reviews. Optional argument number of decimal places of precision must be a positive integer less than 10 which defaults to 1.
1381              
1382             If the query was constructed using
1383             L<Interchange6::Schema::ResultSet::Product/with_average_rating> then
1384             the cached value will be used rather than running a new query.
1385              
1386             =cut
1387              
1388             proxy_resultset_method _average_rating => {
1389             slot => 'average_rating',
1390             resultset_method => 'with_average_rating',
1391             };
1392              
1393             sub average_rating {
1394 14     14 1 77706 my ( $self, $precision ) = @_;
1395              
1396 14 100 100     109 $precision = 1 unless ( defined $precision && $precision =~ /^\d$/ );
1397              
1398 14         65 my $avg = $self->_average_rating;
1399              
1400 14 100       165225 return defined $avg ? sprintf( "%.*f", $precision, $avg ) : undef;
1401             }
1402              
1403             =head2 add_to_reviews
1404              
1405             Reviews should only be associated with parent products. This method returns the related ProductReview records for a parent product. For a child product the ProductReview records for the parent are returned.
1406              
1407             =cut
1408              
1409             # much of this was cargo-culted from DBIx::Class::Relationship::ManyToMany
1410              
1411             sub add_to_reviews {
1412 80     80 1 20998 my $self = shift;
1413 80 100       323 @_ > 0
1414             or $self->throw_exception( "add_to_reviews needs an object or hashref" );
1415 79         412 my $rset_message = $self->result_source->schema->resultset("Message");
1416 79         32753 my $obj;
1417 79 100       367 if ( ref $_[0] ) {
1418 78 100       376 if ( ref $_[0] eq 'HASH' ) {
1419 76         274 $_[0]->{type} = "product_review";
1420 76         316 $obj = $rset_message->create( $_[0] );
1421             }
1422             else {
1423 2         6 $obj = $_[0];
1424 2 100       46 unless ( my $type = $obj->message_type->name eq "product_review" ) {
1425 1         3805 $self->throw_exception(
1426             "cannot add message type $type to reviews" );
1427             }
1428             }
1429             }
1430              
1431 78 100       409430 $self->throw_exception("Bad argument supplied to add_to_reviews")
1432             unless $obj;
1433              
1434             # uncoverable condition left
1435             # uncoverable condition false
1436 77 100       2410 my $sku = $self->canonical_sku ? $self->canonical_sku : $self->sku;
1437 77         5128 $self->product_messages->create( { sku => $sku, messages_id => $obj->id } );
1438 77         173503 return $obj;
1439             }
1440              
1441             =head2 set_reviews
1442              
1443             =over 4
1444              
1445             =item Arguments: (\@hashrefs_of_col_data | \@result_objs)
1446              
1447             =item Return Value: not defined
1448              
1449             =back
1450              
1451             Similar to L<DBIx::Class::Relationship::Base/set_$rel> except that this method DOES delete objects in the table on the right side of the relation.
1452              
1453             =cut
1454              
1455             sub set_reviews {
1456 11     11 1 110089 my $self = shift;
1457 11 100       142 @_ > 0
1458             or $self->throw_exception(
1459             "set_reviews needs a list of objects or hashrefs" );
1460 10 100       75 my @to_set = ( ref( $_[0] ) eq 'ARRAY' ? @{ $_[0] } : @_ );
  1         5  
1461 10         61 $self->product_reviews->delete_all;
1462 10         347398 $self->add_to_reviews( $_ ) for (@to_set);
1463             }
1464              
1465             =head2 quantity_in_stock
1466              
1467             Returns undef if L<inventory_exempt> is true and otherwise returns the
1468             quantity of the product in the inventory. For a product variant the
1469             quantity returned is for the variant itself whereas for a canonical
1470             (parent) product the quantity returned is the total for all variants.
1471              
1472             If the query was constructed using
1473             L<Interchange6::Schema::ResultSet::Product/with_quantity_in_stock> then
1474             the cached value will be used rather than running a new query.
1475              
1476             =cut
1477              
1478             sub quantity_in_stock {
1479 81     81 1 506444 my $self = shift;
1480              
1481             # if already loaded by resultset query then return that value
1482 81 100       361 return $self->get_column('quantity_in_stock')
1483             if $self->has_column_loaded('quantity_in_stock');
1484              
1485 12         161 my $quantity;
1486 12         337 my $variants = $self->variants;
1487 12 100       13815 if ( $variants->has_rows ) {
    100          
1488 3         16929 my $not_exempt = $variants->search( { inventory_exempt => 0 } );
1489 3 100       1480 if ( $not_exempt->has_rows ) {
1490 2         11456 $quantity = $not_exempt->search_related( 'inventory',
1491             { quantity => { '>' => 0 } } )->get_column('quantity')->sum;
1492             }
1493             }
1494             elsif ( ! $self->inventory_exempt ) {
1495 8         30629 my $inventory = $self->inventory;
1496 8 100       15964 $quantity = defined $inventory ? $self->inventory->quantity : 0;
1497             }
1498 12         30900 return $quantity;
1499             }
1500              
1501             =head2 delete
1502              
1503             Overload delete to force removal of any product reviews. Only parent products should have reviews so in the case of child products no attempt is made to delete reviews.
1504              
1505             =cut
1506              
1507             # FIXME: (SysPete) There ought to be a way to force this with cascade delete.
1508              
1509             sub delete {
1510 569     569 1 662416 my ( $self, @args ) = @_;
1511 569         2440 my $guard = $self->result_source->schema->txn_scope_guard;
1512 569 100       222404 $self->product_reviews->delete_all unless defined $self->canonical_sku;
1513 569         5007637 $self->next::method(@args);
1514 569         4493899 $guard->commit;
1515             }
1516              
1517             1;