Product   A
last analyzed

Complexity

Total Complexity 42

Size/Duplication

Total Lines 449
Duplicated Lines 0 %

Importance

Changes 5
Bugs 0 Features 0
Metric Value
wmc 42
eloc 181
c 5
b 0
f 0
dl 0
loc 449
rs 9.0399

20 Methods

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

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;
0 ignored issues
show
Bug introduced by
The type Page was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
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\ListboxField;
21
use SilverStripe\Forms\TextField;
22
use SilverStripe\ORM\DataList;
23
use SilverStripe\ORM\FieldType\DBBoolean;
24
use SilverStripe\ORM\FieldType\DBCurrency;
25
use SilverStripe\ORM\FieldType\DBDecimal;
26
use SilverStripe\ORM\ManyManyList;
27
use SilverStripe\Security\Member;
28
use SilverStripe\SiteConfig\SiteConfig;
29
30
/**
31
 * This is a standard Product page-type with fields like
32
 * Price, Weight, Model and basic management of
33
 * groups.
34
 *
35
 * It also has an associated Product_OrderItem class,
36
 * an extension of OrderItem, which is the mechanism
37
 * that links this page type class to the rest of the
38
 * eCommerce platform. This means you can add an instance
39
 * of this page type to the shopping cart.
40
 *
41
 * @mixin ProductVariationsExtension
42
 *
43
 * @property string $InternalItemID
44
 * @property string $Model
45
 * @property DBCurrency $BasePrice
46
 * @property DBDecimal $Weight
47
 * @property DBDecimal $Height
48
 * @property DBDecimal $Width
49
 * @property DBDecimal $Depth
50
 * @property bool $Featured
51
 * @property bool $AllowPurchase
52
 * @property float $Popularity
53
 * @property int $ImageID
54
 *
55
 * @method ProductCategory[]|ManyManyList ProductCategories()
56
 */
57
class Product extends Page implements Buyable
58
{
59
    private static $db = [
0 ignored issues
show
introduced by
The private property $db is not used, and could be removed.
Loading history...
60
        'InternalItemID' => 'Varchar(30)', //ie SKU, ProductID etc (internal / existing recognition of product)
61
        'Model' => 'Varchar(30)',
62
63
        'BasePrice' => 'Currency(19,4)', // Base retail price the item is marked at.
64
65
        //physical properties
66
        // TODO: Move these to an extension (used in Variations as well)
67
        'Weight' => 'Decimal(12,5)',
68
        'Height' => 'Decimal(12,5)',
69
        'Width' => 'Decimal(12,5)',
70
        'Depth' => 'Decimal(12,5)',
71
72
        'Featured' => 'Boolean',
73
        'AllowPurchase' => 'Boolean',
74
75
        'Popularity' => 'Float' //storage for CalculateProductPopularity task
76
    ];
77
78
    private static $has_one = [
0 ignored issues
show
introduced by
The private property $has_one is not used, and could be removed.
Loading history...
79
        'Image' => Image::class,
80
    ];
81
82
    private static $owns = [
0 ignored issues
show
introduced by
The private property $owns is not used, and could be removed.
Loading history...
83
        'Image'
84
    ];
85
86
    private static $many_many = [
0 ignored issues
show
introduced by
The private property $many_many is not used, and could be removed.
Loading history...
87
        'ProductCategories' => ProductCategory::class,
88
    ];
89
90
    private static $defaults = [
0 ignored issues
show
introduced by
The private property $defaults is not used, and could be removed.
Loading history...
91
        'AllowPurchase' => true,
92
        'ShowInMenus' => false,
93
    ];
94
95
    private static $casting = [
0 ignored issues
show
introduced by
The private property $casting is not used, and could be removed.
Loading history...
96
        'Price' => 'Currency',
97
    ];
98
99
    private static $summary_fields = [
0 ignored issues
show
introduced by
The private property $summary_fields is not used, and could be removed.
Loading history...
100
        'InternalItemID',
101
        'Title',
102
        'BasePrice.NiceOrEmpty',
103
        'IsPurchaseable.Nice',
104
    ];
105
106
    private static $searchable_fields = [
0 ignored issues
show
introduced by
The private property $searchable_fields is not used, and could be removed.
Loading history...
107
        'InternalItemID',
108
        'Title',
109
        'Featured',
110
    ];
111
112
    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...
113
114
    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...
115
116
    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...
117
118
    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...
119
120
    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...
121
122
    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...
123
124
    private static $global_allow_purchase = true;
125
126
    private static $allow_zero_price = false;
127
128
    private static $order_item = OrderItem::class;
129
130
    // Physical Measurement
131
    private static $weight_unit = 'kg';
132
133
    private static $length_unit = 'cm';
134
135
    private static $indexes = [
0 ignored issues
show
introduced by
The private property $indexes is not used, and could be removed.
Loading history...
136
        'Featured' => true,
137
        'AllowPurchase' => true,
138
        'InternalItemID' => true,
139
    ];
140
141
    /**
142
     * Add product fields to CMS
143
     *
144
     * @return FieldList updated field list
145
     */
146
    public function getCMSFields()
147
    {
148
        $self = $this;
149
150
        $this->beforeUpdateCMSFields(
151
            function (FieldList $fields) use ($self) {
152
                $fields->fieldByName('Root.Main.Title')
0 ignored issues
show
Bug introduced by
Are you sure the usage of $fields->fieldByName('Root.Main.Title') targeting SilverStripe\Forms\FieldList::fieldByName() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
153
                    ->setTitle(_t(__CLASS__ . '.PageTitle', 'Product Title'));
154
155
                $fields->addFieldsToTab('Root.Main', [
156
                    TextField::create('InternalItemID', _t(__CLASS__ . '.InternalItemID', 'Product Code/SKU'), '', 30),
157
                    DropdownField::create('ParentID', _t(__CLASS__ . '.Category', 'Category'), $self->getCategoryOptions())
158
                        ->setDescription(_t(__CLASS__ . '.CategoryDescription', 'This is the parent page or default category.')),
159
                    ListboxField::create(
160
                        'ProductCategories',
161
                        _t(__CLASS__ . '.AdditionalCategories', 'Additional Categories'),
162
                        $self->getCategoryOptionsNoParent()
163
                    ),
164
                    TextField::create('Model', _t(__CLASS__ . '.Model', 'Model'), '', 30),
165
                    CheckboxField::create('Featured', _t(__CLASS__ . '.Featured', 'Featured Product')),
166
                    CheckboxField::create('AllowPurchase', _t(__CLASS__ . '.AllowPurchase', 'Allow product to be purchased'), 1),
167
                ], 'Content');
168
169
                $fields->addFieldsToTab(
170
                    'Root.Pricing',
171
                    [
172
                        TextField::create('BasePrice', $this->fieldLabel('BasePrice'))
173
                        ->setDescription(_t(__CLASS__ . '.PriceDesc', 'Base price to sell this product at.'))
174
                        ->setMaxLength(12),
175
                    ]
176
                );
177
178
                $fieldSubstitutes = [
179
                    'LengthUnit' => $self::config()->length_unit
180
                ];
181
182
                $fields->addFieldsToTab(
183
                    'Root.Shipping',
184
                    [
185
                    TextField::create(
186
                        'Weight',
187
                        _t(
188
                            __CLASS__ . '.WeightWithUnit',
189
                            'Weight ({WeightUnit})',
190
                            '',
191
                            [
192
                            'WeightUnit' => self::config()->weight_unit
193
                            ]
194
                        ),
195
                        '',
196
                        12
197
                    ),
198
                    TextField::create(
199
                        'Height',
200
                        _t(__CLASS__ . '.HeightWithUnit', 'Height ({LengthUnit})', '', $fieldSubstitutes),
201
                        '',
202
                        12
203
                    ),
204
                    TextField::create(
205
                        'Width',
206
                        _t(__CLASS__ . '.WidthWithUnit', 'Width ({LengthUnit})', '', $fieldSubstitutes),
207
                        '',
208
                        12
209
                    ),
210
                    TextField::create(
211
                        'Depth',
212
                        _t(__CLASS__ . '.DepthWithUnit', 'Depth ({LengthUnit})', '', $fieldSubstitutes),
213
                        '',
214
                        12
215
                    ),
216
                    ]
217
                );
218
219
                if (!$fields->dataFieldByName('Image')) {
220
                    $fields->addFieldToTab(
221
                        'Root.Images',
222
                        UploadField::create('Image', _t(__CLASS__ . '.Image', 'Product Image'))
223
                    );
224
                }
225
            }
226
        );
227
228
        return parent::getCMSFields();
229
    }
230
231
    /**
232
     * Add missing translations to the fieldLabels
233
     */
234
    public function fieldLabels($includerelations = true)
235
    {
236
        $labels = parent::fieldLabels($includerelations);
237
238
        $labels['Title'] = _t(__CLASS__ . '.PageTitle', 'Product Title');
239
        $labels['IsPurchaseable'] = $labels['IsPurchaseable.Nice'] = _t(__CLASS__ . '.IsPurchaseable', 'Is Purchaseable');
240
        $labels['BasePrice.NiceOrEmpty'] = _t(__CLASS__ . '.db_BasePrice', 'Price');
241
242
        return $labels;
243
    }
244
245
    /**
246
     * Helper function for generating list of categories to select from.
247
     *
248
     * @return array categories
249
     */
250
    private function getCategoryOptions()
251
    {
252
        $categories = ProductCategory::get()->map('ID', 'NestedTitle')->toArray();
253
        $categories = [
254
            0 => _t('SilverStripe\CMS\Model\SiteTree.PARENTTYPE_ROOT', 'Top-level page'),
255
        ] + $categories;
256
        if ($this->ParentID && !($this->Parent() instanceof ProductCategory)) {
257
            $categories = [
258
                $this->ParentID => $this->Parent()->Title . ' (' . $this->Parent()->i18n_singular_name() . ')',
259
            ] + $categories;
260
        }
261
262
        return $categories;
263
    }
264
265
    /**
266
     * Helper function for generating a list of additional categories excluding the main parent.
267
     *
268
     * @return array categories
269
     */
270
    private function getCategoryOptionsNoParent()
271
    {
272
        $ancestors = $this->getAncestors()->column('ID');
273
        $categories = ProductCategory::get();
274
        if (!empty($ancestors)) {
275
            $categories = $categories->exclude('ID', $ancestors);
276
        }
277
        return $categories->map('ID', 'NestedTitle')->toArray();
278
    }
279
280
    /**
281
     * Get ids of all categories that this product appears in.
282
     *
283
     * @return array ids list
284
     */
285
    public function getCategoryIDs()
286
    {
287
        $ids = [];
288
        //ancestors
289
        foreach ($this->getAncestors() as $ancestor) {
290
            $ids[$ancestor->ID] = $ancestor->ID;
291
        }
292
        //additional categories
293
        $ids += $this->ProductCategories()->getIDList();
294
295
        return $ids;
296
    }
297
298
    /**
299
     * Get all categories that this product appears in.
300
     *
301
     * @return DataList category data list
302
     */
303
    public function getCategories()
304
    {
305
        return ProductCategory::get()->byIDs($this->getCategoryIDs());
306
    }
307
308
    /**
309
     * Conditions for whether a product can be purchased:
310
     *  - global allow purchase is enabled
311
     *  - product AllowPurchase field is true
312
     *  - if variations, then one of them needs to be purchasable
313
     *  - if not variations, selling price must be above 0
314
     *
315
     * Other conditions may be added by decorating with the canPurchase function
316
     *
317
     * @param Member $member
318
     * @param int    $quantity
319
     *
320
     * @return boolean
321
     */
322
    public function canPurchase($member = null, $quantity = 1)
323
    {
324
        $global = self::config()->global_allow_purchase;
325
        if (!$global || !$this->AllowPurchase) {
326
            return false;
327
        }
328
        $allowpurchase = false;
329
        $extension = self::has_extension(ProductVariationsExtension::class);
330
        if ($extension && Variation::get()->filter('ProductID', $this->ID)->first()) {
331
            foreach ($this->Variations() as $variation) {
332
                if ($variation->canPurchase($member, $quantity)) {
333
                    $allowpurchase = true;
334
                    break;
335
                }
336
            }
337
        } else {
338
            $allowpurchase = ($this->sellingPrice() > 0 || self::config()->allow_zero_price);
339
        }
340
341
        // Standard mechanism for accepting permission changes from decorators
342
        $permissions = $this->extend('canPurchase', $member, $quantity);
343
        $permissions[] = $allowpurchase;
344
        return min($permissions);
345
    }
346
347
    /**
348
     * Returns the purchaseable flag as `DBBoolean`. Useful for templates or summaries.
349
     * @return DBBoolean
350
     */
351
    public function IsPurchaseable()
352
    {
353
        return DBBoolean::create_field(DBBoolean::class, $this->canPurchase());
354
    }
355
356
    /**
357
     * Returns if the product is already in the shopping cart.
358
     *
359
     * @return boolean
360
     */
361
    public function IsInCart()
362
    {
363
        $item = $this->Item();
364
        return $item && $item->exists() && $item->Quantity > 0;
365
    }
366
367
    /**
368
     * Returns the order item which contains the product
369
     *
370
     * @return OrderItem
371
     */
372
    public function Item()
373
    {
374
        $filter = [];
375
        $this->extend('updateItemFilter', $filter);
376
        $item = ShoppingCart::singleton()->get($this, $filter);
377
        if (!$item) {
378
            //return dummy item so that we can still make use of Item
379
            $item = $this->createItem();
380
        }
381
        $this->extend('updateDummyItem', $item);
382
        return $item;
383
    }
384
385
    /**
386
     * @see Buyable::createItem()
387
     */
388
    public function createItem($quantity = 1, $filter = null)
389
    {
390
        $orderitem = self::config()->order_item;
391
        $item = new $orderitem();
392
        $item->ProductID = $this->ID;
393
        if ($filter) {
394
            //TODO: make this a bit safer, perhaps intersect with allowed fields
395
            $item->update($filter);
396
        }
397
        $item->Quantity = $quantity;
398
        return $item;
399
    }
400
401
    /**
402
     * The raw retail price the visitor will get when they
403
     * add to cart. Can include discounts or markups on the base price.
404
     */
405
    public function sellingPrice()
406
    {
407
        $price = $this->BasePrice;
408
        //TODO: this is not ideal, because prices manipulations will not happen in a known order
409
        $this->extend('updateSellingPrice', $price);
410
        //prevent negative values
411
        $price = $price < 0 ? 0 : $price;
412
413
        // NOTE: Ideally, this would be dependent on the locale but as of
414
        // now the Silverstripe Currency field type has 2 hardcoded all over
415
        // the place. In the mean time there is an issue where the displayed
416
        // unit price can not exactly equal the multiplied price on an order
417
        // (i.e. if the calculated price is 3.145 it will display as 3.15.
418
        // so if I put 10 of them in my cart I will expect the price to be
419
        // 31.50 not 31.45).
420
        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...
Bug introduced by
It seems like $price can also be of type SilverStripe\ORM\FieldType\DBCurrency; however, parameter $num of round() does only seem to accept double|integer, maybe add an additional type check? ( Ignorable by Annotation )

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

420
        return round(/** @scrutinizer ignore-type */ $price, Order::config()->rounding_precision);
Loading history...
421
    }
422
423
    /**
424
     * This value is cased to Currency in temlates.
425
     */
426
    public function getPrice()
427
    {
428
        return $this->sellingPrice();
429
    }
430
431
    public function setPrice($price)
432
    {
433
        $price = $price < 0 ? 0 : $price;
434
        $this->setField('BasePrice', $price);
435
    }
436
437
    /**
438
     * Allow orphaned products to be viewed.
439
     */
440
    public function isOrphaned()
441
    {
442
        return false;
443
    }
444
445
    /**
446
     * If the product does not have an image, and a default image
447
     * is defined in SiteConfig, return that instead.
448
     *
449
     * @return Image
450
     * @throws \Exception
451
     */
452
    public function Image()
453
    {
454
        $image = $this->getComponent('Image');
455
        $this->extend('updateImage', $image);
456
457
        if ($image && $image->exists()) {
458
            return $image;
459
        }
460
        $image = SiteConfig::current_site_config()->DefaultProductImage();
461
        if ($image && $image->exists()) {
462
            return $image;
463
        }
464
        return null;
465
    }
466
467
    /**
468
     * Link to add this product to cart.
469
     *
470
     * @return string|false link
471
     */
472
    public function addLink()
473
    {
474
        return ShoppingCartController::add_item_link($this);
475
    }
476
477
    /**
478
     * Link to remove one of this product from cart.
479
     *
480
     * @return string|false link
481
     */
482
    public function removeLink()
483
    {
484
        return ShoppingCartController::remove_item_link($this);
485
    }
486
487
    /**
488
     * Link to remove all of this product from cart.
489
     *
490
     * @return string|false link
491
     */
492
    public function removeallLink()
493
    {
494
        return ShoppingCartController::remove_all_item_link($this);
495
    }
496
497
    /**
498
     * Get the form class to use to edit this product in the frontend
499
     * @return string FQCN
500
     */
501
    public function getFormClass()
502
    {
503
        $formClass = AddProductForm::class;
504
        $this->extend('updateFormClass', $formClass);
505
        return $formClass;
506
    }
507
}
508