| Total Complexity | 42 |
| Total Lines | 449 |
| Duplicated Lines | 0 % |
| Changes | 5 | ||
| Bugs | 0 | Features | 0 |
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 |
||
| 57 | class Product extends Page implements Buyable |
||
| 58 | { |
||
| 59 | private static $db = [ |
||
| 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 = [ |
||
| 79 | 'Image' => Image::class, |
||
| 80 | ]; |
||
| 81 | |||
| 82 | private static $owns = [ |
||
| 83 | 'Image' |
||
| 84 | ]; |
||
| 85 | |||
| 86 | private static $many_many = [ |
||
| 87 | 'ProductCategories' => ProductCategory::class, |
||
| 88 | ]; |
||
| 89 | |||
| 90 | private static $defaults = [ |
||
| 91 | 'AllowPurchase' => true, |
||
| 92 | 'ShowInMenus' => false, |
||
| 93 | ]; |
||
| 94 | |||
| 95 | private static $casting = [ |
||
| 96 | 'Price' => 'Currency', |
||
| 97 | ]; |
||
| 98 | |||
| 99 | private static $summary_fields = [ |
||
| 100 | 'InternalItemID', |
||
| 101 | 'Title', |
||
| 102 | 'BasePrice.NiceOrEmpty', |
||
| 103 | 'IsPurchaseable.Nice', |
||
| 104 | ]; |
||
| 105 | |||
| 106 | private static $searchable_fields = [ |
||
| 107 | 'InternalItemID', |
||
| 108 | 'Title', |
||
| 109 | 'Featured', |
||
| 110 | ]; |
||
| 111 | |||
| 112 | private static $table_name = 'SilverShop_Product'; |
||
| 113 | |||
| 114 | private static $singular_name = 'Product'; |
||
| 115 | |||
| 116 | private static $plural_name = 'Products'; |
||
| 117 | |||
| 118 | private static $icon = 'silvershop/core: client/dist/images/icons/package.gif'; |
||
| 119 | |||
| 120 | private static $default_parent = ProductCategory::class; |
||
| 121 | |||
| 122 | private static $default_sort = '"Title" ASC'; |
||
| 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 = [ |
||
| 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') |
||
| 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() |
||
| 354 | } |
||
| 355 | |||
| 356 | /** |
||
| 357 | * Returns if the product is already in the shopping cart. |
||
| 358 | * |
||
| 359 | * @return boolean |
||
| 360 | */ |
||
| 361 | public function IsInCart() |
||
| 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); |
||
| 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() |
||
| 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() |
||
| 475 | } |
||
| 476 | |||
| 477 | /** |
||
| 478 | * Link to remove one of this product from cart. |
||
| 479 | * |
||
| 480 | * @return string|false link |
||
| 481 | */ |
||
| 482 | public function removeLink() |
||
| 485 | } |
||
| 486 | |||
| 487 | /** |
||
| 488 | * Link to remove all of this product from cart. |
||
| 489 | * |
||
| 490 | * @return string|false link |
||
| 491 | */ |
||
| 492 | public function removeallLink() |
||
| 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() |
||
| 508 |
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:For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths