Completed
Push — master ( ecaba6...f0c386 )
by Roman
13s
created

Product   B

Complexity

Total Complexity 43

Size/Duplication

Total Lines 454
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
dl 0
loc 454
rs 8.3157
c 0
b 0
f 0
wmc 43

21 Methods

Rating   Name   Duplication   Size   Complexity  
A getCategoryIDs() 0 11 2
C canPurchase() 0 23 8
A getCategoryOptions() 0 13 3
A getCategoryOptionsNoParent() 0 8 2
A getCategories() 0 3 1
A addLink() 0 3 1
B Image() 0 13 5
A fieldLabels() 0 9 1
A Item() 0 11 2
B getCMSFields() 0 81 2
A createItem() 0 11 2
A removeLink() 0 3 1
A removeallLink() 0 3 1
A IsPurchaseable() 0 3 1
A getFormClass() 0 5 1
A IsInCart() 0 4 3
A Link() 0 5 1
A sellingPrice() 0 16 2
A isOrphaned() 0 3 1
A getPrice() 0 3 1
A setPrice() 0 4 2

How to fix   Complexity   

Complex Class

Complex classes like Product often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Product, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace SilverShop\Page;
4
5
use Page;
6
use SilverShop\Cart\ShoppingCart;
7
use SilverShop\Cart\ShoppingCartController;
8
use SilverShop\Extension\ProductVariationsExtension;
9
use SilverShop\Forms\AddProductForm;
10
use SilverShop\Model\Buyable;
11
use SilverShop\Model\Order;
12
use SilverShop\Model\Product\OrderItem;
13
use SilverShop\Model\Variation\Variation;
14
use SilverStripe\AssetAdmin\Forms\UploadField;
15
use SilverStripe\Assets\Image;
16
use SilverStripe\Control\Director;
17
use SilverStripe\Forms\CheckboxField;
18
use SilverStripe\Forms\DropdownField;
19
use SilverStripe\Forms\FieldList;
20
use SilverStripe\Forms\TextField;
21
use SilverStripe\ORM\DataList;
22
use SilverStripe\ORM\FieldType\DBBoolean;
23
use SilverStripe\ORM\FieldType\DBCurrency;
24
use SilverStripe\ORM\FieldType\DBDecimal;
25
use SilverStripe\ORM\ManyManyList;
26
use SilverStripe\Security\Member;
27
use SilverStripe\SiteConfig\SiteConfig;
28
29
/**
30
 * This is a standard Product page-type with fields like
31
 * Price, Weight, Model and basic management of
32
 * groups.
33
 *
34
 * It also has an associated Product_OrderItem class,
35
 * an extension of OrderItem, which is the mechanism
36
 * that links this page type class to the rest of the
37
 * eCommerce platform. This means you can add an instance
38
 * of this page type to the shopping cart.
39
 *
40
 * @mixin ProductVariationsExtension
41
 *
42
 * @property string $InternalItemID
43
 * @property string $Model
44
 * @property DBCurrency $BasePrice
45
 * @property DBDecimal $Weight
46
 * @property DBDecimal $Height
47
 * @property DBDecimal $Width
48
 * @property DBDecimal $Depth
49
 * @property bool $Featured
50
 * @property bool $AllowPurchase
51
 * @property float $Popularity
52
 * @property int $ImageID
53
 *
54
 * @method ProductCategory[]|ManyManyList ProductCategories()
55
 */
56
class Product extends Page implements Buyable
57
{
58
    private static $db = [
0 ignored issues
show
introduced by
The private property $db is not used, and could be removed.
Loading history...
59
        'InternalItemID' => 'Varchar(30)', //ie SKU, ProductID etc (internal / existing recognition of product)
60
        'Model' => 'Varchar(30)',
61
62
        'BasePrice' => 'Currency(19,4)', // Base retail price the item is marked at.
63
64
        //physical properties
65
        // TODO: Move these to an extension (used in Variations as well)
66
        'Weight' => 'Decimal(12,5)',
67
        'Height' => 'Decimal(12,5)',
68
        'Width' => 'Decimal(12,5)',
69
        'Depth' => 'Decimal(12,5)',
70
71
        'Featured' => 'Boolean',
72
        'AllowPurchase' => 'Boolean',
73
74
        'Popularity' => 'Float' //storage for CalculateProductPopularity task
75
    ];
76
77
    private static $has_one = [
0 ignored issues
show
introduced by
The private property $has_one is not used, and could be removed.
Loading history...
78
        'Image' => Image::class,
79
    ];
80
81
    private static $owns = [
0 ignored issues
show
introduced by
The private property $owns is not used, and could be removed.
Loading history...
82
        'Image'
83
    ];
84
85
    private static $many_many = [
0 ignored issues
show
introduced by
The private property $many_many is not used, and could be removed.
Loading history...
86
        'ProductCategories' => ProductCategory::class,
87
    ];
88
89
    private static $defaults = [
0 ignored issues
show
introduced by
The private property $defaults is not used, and could be removed.
Loading history...
90
        'AllowPurchase' => true,
91
        'ShowInMenus' => false,
92
    ];
93
94
    private static $casting = [
0 ignored issues
show
introduced by
The private property $casting is not used, and could be removed.
Loading history...
95
        'Price' => 'Currency',
96
    ];
97
98
    private static $summary_fields = [
0 ignored issues
show
introduced by
The private property $summary_fields is not used, and could be removed.
Loading history...
99
        'InternalItemID',
100
        'Title',
101
        'BasePrice.NiceOrEmpty',
102
        'IsPurchaseable.Nice',
103
    ];
104
105
    private static $searchable_fields = [
0 ignored issues
show
introduced by
The private property $searchable_fields is not used, and could be removed.
Loading history...
106
        'InternalItemID',
107
        'Title',
108
        'Featured',
109
    ];
110
111
    private static $table_name = 'SilverShop_Product';
0 ignored issues
show
introduced by
The private property $table_name is not used, and could be removed.
Loading history...
112
113
    private static $singular_name = 'Product';
0 ignored issues
show
introduced by
The private property $singular_name is not used, and could be removed.
Loading history...
114
115
    private static $plural_name = 'Products';
0 ignored issues
show
introduced by
The private property $plural_name is not used, and could be removed.
Loading history...
116
117
    private static $icon = 'silvershop/core: client/dist/images/icons/package.gif';
0 ignored issues
show
introduced by
The private property $icon is not used, and could be removed.
Loading history...
118
119
    private static $default_parent = ProductCategory::class;
0 ignored issues
show
introduced by
The private property $default_parent is not used, and could be removed.
Loading history...
120
121
    private static $default_sort = '"Title" ASC';
0 ignored issues
show
introduced by
The private property $default_sort is not used, and could be removed.
Loading history...
122
123
    private static $global_allow_purchase = true;
124
125
    private static $allow_zero_price = false;
126
127
    private static $order_item = OrderItem::class;
128
129
    // Physical Measurement
130
    private static $weight_unit = 'kg';
131
132
    private static $length_unit = 'cm';
133
134
    private static $indexes = [
0 ignored issues
show
introduced by
The private property $indexes is not used, and could be removed.
Loading history...
135
        'Featured' => true,
136
        'AllowPurchase' => true,
137
        'InternalItemID' => true,
138
    ];
139
140
    /**
141
     * Add product fields to CMS
142
     *
143
     * @return FieldList updated field list
144
     */
145
    public function getCMSFields()
146
    {
147
        $self = $this;
148
149
        $this->beforeUpdateCMSFields(
150
            function (FieldList $fields) use ($self) {
151
                $fields->fieldByName('Root.Main.Title')
152
                    ->setTitle(_t(__CLASS__ . '.PageTitle', 'Product Title'));
153
154
                $fields->addFieldsToTab(
155
                    'Root.Main',
156
                    [
157
                        TextField::create('InternalItemID', _t(__CLASS__ . '.InternalItemID', 'Product Code/SKU'), '', 30),
0 ignored issues
show
Bug introduced by
30 of type integer is incompatible with the type array expected by parameter $args of SilverStripe\View\ViewableData::create(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

157
                        TextField::create('InternalItemID', _t(__CLASS__ . '.InternalItemID', 'Product Code/SKU'), '', /** @scrutinizer ignore-type */ 30),
Loading history...
Bug introduced by
'InternalItemID' of type string is incompatible with the type array expected by parameter $args of SilverStripe\View\ViewableData::create(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

157
                        TextField::create(/** @scrutinizer ignore-type */ 'InternalItemID', _t(__CLASS__ . '.InternalItemID', 'Product Code/SKU'), '', 30),
Loading history...
158
                        DropdownField::create('ParentID', _t(__CLASS__ . '.Category', 'Category'), $self->getCategoryOptions())
159
                            ->setDescription(_t(__CLASS__ . '.CategoryDescription', 'This is the parent page or default category.')),
160
                        TextField::create('Model', _t(__CLASS__ . '.Model', 'Model'), '', 30),
161
                        CheckboxField::create('Featured', _t(__CLASS__ . '.Featured', 'Featured Product')),
162
                        CheckboxField::create('AllowPurchase', _t(__CLASS__ . '.AllowPurchase', 'Allow product to be purchased'), 1),
163
                    ]
164
                );
165
166
                $fields->addFieldsToTab(
167
                    'Root.Pricing',
168
                    [
169
                        TextField::create('BasePrice', $this->fieldLabel('BasePrice'))
170
                        ->setDescription(_t(__CLASS__ . '.PriceDesc', 'Base price to sell this product at.'))
171
                        ->setMaxLength(12),
172
                    ]
173
                );
174
175
                $fieldSubstitutes = [
176
                    'LengthUnit' => $self::config()->length_unit
177
                ];
178
179
                $fields->addFieldsToTab(
180
                    'Root.Shipping',
181
                    [
182
                    TextField::create(
183
                        'Weight',
184
                        _t(
185
                            __CLASS__ . '.WeightWithUnit',
186
                            'Weight ({WeightUnit})',
187
                            '',
188
                            [
189
                            'WeightUnit' => self::config()->weight_unit
190
                            ]
191
                        ),
192
                        '',
193
                        12
194
                    ),
195
                    TextField::create(
196
                        'Height',
197
                        _t(__CLASS__ . '.HeightWithUnit', 'Height ({LengthUnit})', '', $fieldSubstitutes),
198
                        '',
199
                        12
200
                    ),
201
                    TextField::create(
202
                        'Width',
203
                        _t(__CLASS__ . '.WidthWithUnit', 'Width ({LengthUnit})', '', $fieldSubstitutes),
204
                        '',
205
                        12
206
                    ),
207
                    TextField::create(
208
                        'Depth',
209
                        _t(__CLASS__ . '.DepthWithUnit', 'Depth ({LengthUnit})', '', $fieldSubstitutes),
210
                        '',
211
                        12
212
                    ),
213
                    ]
214
                );
215
216
                if (!$fields->dataFieldByName('Image')) {
217
                    $fields->addFieldToTab(
218
                        'Root.Images',
219
                        UploadField::create('Image', _t(__CLASS__ . '.Image', 'Product Image'))
220
                    );
221
                }
222
            }
223
        );
224
225
        return parent::getCMSFields();
226
    }
227
228
    /**
229
     * Add missing translations to the fieldLabels
230
     */
231
    public function fieldLabels($includerelations = true)
232
    {
233
        $labels = parent::fieldLabels($includerelations);
234
235
        $labels['Title'] = _t(__CLASS__ . '.PageTitle', 'Product Title');
236
        $labels['IsPurchaseable'] = $labels['IsPurchaseable.Nice'] = _t(__CLASS__ . '.IsPurchaseable', 'Is Purchaseable');
237
        $labels['BasePrice.NiceOrEmpty'] = _t(__CLASS__ . '.db_BasePrice', 'Price');
238
239
        return $labels;
240
    }
241
242
    /**
243
     * Helper function for generating list of categories to select from.
244
     *
245
     * @return array categories
246
     */
247
    private function getCategoryOptions()
248
    {
249
        $categories = ProductCategory::get()->map('ID', 'NestedTitle')->toArray();
250
        $categories = [
251
            0 => _t('SilverStripe\CMS\Model\SiteTree.PARENTTYPE_ROOT', 'Top-level page'),
252
        ] + $categories;
253
        if ($this->ParentID && !($this->Parent() instanceof ProductCategory)) {
254
            $categories = [
255
                $this->ParentID => $this->Parent()->Title . ' (' . $this->Parent()->i18n_singular_name() . ')',
256
            ] + $categories;
257
        }
258
259
        return $categories;
260
    }
261
262
    /**
263
     * Helper function for generating a list of additional categories excluding the main parent.
264
     *
265
     * @return array categories
266
     */
267
    private function getCategoryOptionsNoParent()
0 ignored issues
show
Unused Code introduced by
The method getCategoryOptionsNoParent() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
268
    {
269
        $ancestors = $this->getAncestors()->map('ID', 'ID');
0 ignored issues
show
Bug introduced by
The method getAncestors() does not exist on SilverShop\Page\Product. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

269
        $ancestors = $this->/** @scrutinizer ignore-call */ getAncestors()->map('ID', 'ID');
Loading history...
270
        $categories = ProductCategory::get();
271
        if (!empty($ancestors)) {
272
            $categories->filter('ID:not', $ancestors);
273
        }
274
        return $categories->map('ID', 'NestedTitle')->toArray();
275
    }
276
277
    /**
278
     * Get ids of all categories that this product appears in.
279
     *
280
     * @return array ids list
281
     */
282
    public function getCategoryIDs()
283
    {
284
        $ids = array();
285
        //ancestors
286
        foreach ($this->getAncestors() as $ancestor) {
287
            $ids[$ancestor->ID] = $ancestor->ID;
288
        }
289
        //additional categories
290
        $ids += $this->ProductCategories()->getIDList();
291
292
        return $ids;
293
    }
294
295
    /**
296
     * Get all categories that this product appears in.
297
     *
298
     * @return DataList category data list
299
     */
300
    public function getCategories()
301
    {
302
        return ProductCategory::get()->byIDs($this->getCategoryIDs());
303
    }
304
305
    /**
306
     * Conditions for whether a product can be purchased:
307
     *  - global allow purchase is enabled
308
     *  - product AllowPurchase field is true
309
     *  - if variations, then one of them needs to be purchasable
310
     *  - if not variations, selling price must be above 0
311
     *
312
     * Other conditions may be added by decorating with the canPurchase function
313
     *
314
     * @param Member $member
315
     * @param int    $quantity
316
     *
317
     * @return boolean
318
     */
319
    public function canPurchase($member = null, $quantity = 1)
320
    {
321
        $global = self::config()->global_allow_purchase;
322
        if (!$global || !$this->AllowPurchase) {
323
            return false;
324
        }
325
        $allowpurchase = false;
326
        $extension = self::has_extension('ProductVariationsExtension');
327
        if ($extension && Variation::get()->filter('ProductID', $this->ID)->first()) {
328
            foreach ($this->Variations() as $variation) {
0 ignored issues
show
Bug introduced by
The method Variations() does not exist on SilverShop\Page\Product. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

328
            foreach ($this->/** @scrutinizer ignore-call */ Variations() as $variation) {
Loading history...
329
                if ($variation->canPurchase($member, $quantity)) {
330
                    $allowpurchase = true;
331
                    break;
332
                }
333
            }
334
        } else {
335
            $allowpurchase = ($this->sellingPrice() > 0 || self::config()->allow_zero_price);
336
        }
337
338
        // Standard mechanism for accepting permission changes from decorators
339
        $permissions = $this->extend('canPurchase', $member, $quantity);
340
        $permissions[] = $allowpurchase;
341
        return min($permissions);
342
    }
343
344
    /**
345
     * Returns the purchaseable flag as `DBBoolean`. Useful for templates or summaries.
346
     * @return DBBoolean
347
     */
348
    public function IsPurchaseable()
349
    {
350
        return DBBoolean::create_field(DBBoolean::class, $this->canPurchase());
351
    }
352
353
    /**
354
     * Returns if the product is already in the shopping cart.
355
     *
356
     * @return boolean
357
     */
358
    public function IsInCart()
359
    {
360
        $item = $this->Item();
361
        return $item && $item->exists() && $item->Quantity > 0;
362
    }
363
364
    /**
365
     * Returns the order item which contains the product
366
     *
367
     * @return OrderItem
368
     */
369
    public function Item()
370
    {
371
        $filter = array();
372
        $this->extend('updateItemFilter', $filter);
373
        $item = ShoppingCart::singleton()->get($this, $filter);
374
        if (!$item) {
375
            //return dummy item so that we can still make use of Item
376
            $item = $this->createItem();
377
        }
378
        $this->extend('updateDummyItem', $item);
379
        return $item;
380
    }
381
382
    /**
383
     * @see Buyable::createItem()
384
     */
385
    public function createItem($quantity = 1, $filter = null)
386
    {
387
        $orderitem = self::config()->order_item;
388
        $item = new $orderitem();
389
        $item->ProductID = $this->ID;
390
        if ($filter) {
391
            //TODO: make this a bit safer, perhaps intersect with allowed fields
392
            $item->update($filter);
393
        }
394
        $item->Quantity = $quantity;
395
        return $item;
396
    }
397
398
    /**
399
     * The raw retail price the visitor will get when they
400
     * add to cart. Can include discounts or markups on the base price.
401
     */
402
    public function sellingPrice()
403
    {
404
        $price = $this->BasePrice;
405
        //TODO: this is not ideal, because prices manipulations will not happen in a known order
406
        $this->extend('updateSellingPrice', $price);
407
        //prevent negative values
408
        $price = $price < 0 ? 0 : $price;
409
410
        // NOTE: Ideally, this would be dependent on the locale but as of
411
        // now the Silverstripe Currency field type has 2 hardcoded all over
412
        // the place. In the mean time there is an issue where the displayed
413
        // unit price can not exactly equal the multiplied price on an order
414
        // (i.e. if the calculated price is 3.145 it will display as 3.15.
415
        // so if I put 10 of them in my cart I will expect the price to be
416
        // 31.50 not 31.45).
0 ignored issues
show
Unused Code Comprehensibility introduced by
38% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
417
        return round($price, Order::config()->rounding_precision);
0 ignored issues
show
Bug Best Practice introduced by
The expression return round($price, Sil...()->rounding_precision) returns the type double which is incompatible with the return type mandated by SilverShop\Model\Buyable::sellingPrice() of SilverShop\ORM\FieldType\ShopCurrency.

In the issue above, the returned value is violating the contract defined by the mentioned interface.

Let's take a look at an example:

interface HasName {
    /** @return string */
    public function getName();
}

class Name {
    public $name;
}

class User implements HasName {
    /** @return string|Name */
    public function getName() {
        return new Name('foo'); // This is a violation of the ``HasName`` interface
                                // which only allows a string value to be returned.
    }
}
Loading history...
418
    }
419
420
    /**
421
     * This value is cased to Currency in temlates.
422
     */
423
    public function getPrice()
424
    {
425
        return $this->sellingPrice();
426
    }
427
428
    public function setPrice($price)
429
    {
430
        $price = $price < 0 ? 0 : $price;
431
        $this->setField('BasePrice', $price);
432
    }
433
434
    /**
435
     * Allow orphaned products to be viewed.
436
     */
437
    public function isOrphaned()
438
    {
439
        return false;
440
    }
441
442
    public function Link($action = null)
443
    {
444
        $link = parent::Link($action);
445
        $this->extend('updateLink', $link);
446
        return $link;
447
    }
448
449
    /**
450
     * If the product does not have an image, and a default image
451
     * is defined in SiteConfig, return that instead.
452
     *
453
     * @return Image
454
     * @throws \Exception
455
     */
456
    public function Image()
457
    {
458
        $image = $this->getComponent('Image');
459
        $this->extend('updateImage', $image);
460
461
        if ($image && $image->exists()) {
462
            return $image;
463
        }
464
        $image = SiteConfig::current_site_config()->DefaultProductImage();
465
        if ($image && $image->exists()) {
466
            return $image;
467
        }
468
        return null;
469
    }
470
471
    /**
472
     * Link to add this product to cart.
473
     *
474
     * @return string link
475
     */
476
    public function addLink()
477
    {
478
        return ShoppingCartController::add_item_link($this);
0 ignored issues
show
Bug Best Practice introduced by
The expression return SilverShop\Cart\S...r::add_item_link($this) could also return false which is incompatible with the documented return type string. Did you maybe forget to handle an error condition?

If the returned type also contains false, it is an indicator that maybe an error condition leading to the specific return statement remains unhandled.

Loading history...
479
    }
480
481
    /**
482
     * Link to remove one of this product from cart.
483
     *
484
     * @return string link
485
     */
486
    public function removeLink()
487
    {
488
        return ShoppingCartController::remove_item_link($this);
0 ignored issues
show
Bug Best Practice introduced by
The expression return SilverShop\Cart\S...remove_item_link($this) could also return false which is incompatible with the documented return type string. Did you maybe forget to handle an error condition?

If the returned type also contains false, it is an indicator that maybe an error condition leading to the specific return statement remains unhandled.

Loading history...
489
    }
490
491
    /**
492
     * Link to remove all of this product from cart.
493
     *
494
     * @return string link
495
     */
496
    public function removeallLink()
497
    {
498
        return ShoppingCartController::remove_all_item_link($this);
0 ignored issues
show
Bug Best Practice introduced by
The expression return SilverShop\Cart\S...ve_all_item_link($this) could also return false which is incompatible with the documented return type string. Did you maybe forget to handle an error condition?

If the returned type also contains false, it is an indicator that maybe an error condition leading to the specific return statement remains unhandled.

Loading history...
499
    }
500
501
    /**
502
     * Get the form class to use to edit this product in the frontend
503
     * @return string FQCN
504
     */
505
    public function getFormClass()
506
    {
507
        $formClass = AddProductForm::class;
508
        $this->extend('updateFormClass', $formClass);
509
        return $formClass;
510
    }
511
}
512