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; |
||
0 ignored issues
–
show
|
|||
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 = [ |
||
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 = array(); |
||
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 = array(); |
||
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() |
||
441 | { |
||
442 | return false; |
||
443 | } |
||
444 | |||
445 | public function Link($action = null) |
||
446 | { |
||
447 | $link = parent::Link($action); |
||
448 | $this->extend('updateLink', $link); |
||
449 | return $link; |
||
450 | } |
||
451 | |||
452 | /** |
||
453 | * If the product does not have an image, and a default image |
||
454 | * is defined in SiteConfig, return that instead. |
||
455 | * |
||
456 | * @return Image |
||
457 | * @throws \Exception |
||
458 | */ |
||
459 | public function Image() |
||
460 | { |
||
461 | $image = $this->getComponent('Image'); |
||
462 | $this->extend('updateImage', $image); |
||
463 | |||
464 | if ($image && $image->exists()) { |
||
465 | return $image; |
||
466 | } |
||
467 | $image = SiteConfig::current_site_config()->DefaultProductImage(); |
||
468 | if ($image && $image->exists()) { |
||
469 | return $image; |
||
470 | } |
||
471 | return null; |
||
472 | } |
||
473 | |||
474 | /** |
||
475 | * Link to add this product to cart. |
||
476 | * |
||
477 | * @return string link |
||
478 | */ |
||
479 | public function addLink() |
||
480 | { |
||
481 | return ShoppingCartController::add_item_link($this); |
||
482 | } |
||
483 | |||
484 | /** |
||
485 | * Link to remove one of this product from cart. |
||
486 | * |
||
487 | * @return string link |
||
488 | */ |
||
489 | public function removeLink() |
||
490 | { |
||
491 | return ShoppingCartController::remove_item_link($this); |
||
492 | } |
||
493 | |||
494 | /** |
||
495 | * Link to remove all of this product from cart. |
||
496 | * |
||
497 | * @return string link |
||
498 | */ |
||
499 | public function removeallLink() |
||
500 | { |
||
501 | return ShoppingCartController::remove_all_item_link($this); |
||
502 | } |
||
503 | |||
504 | /** |
||
505 | * Get the form class to use to edit this product in the frontend |
||
506 | * @return string FQCN |
||
507 | */ |
||
508 | public function getFormClass() |
||
509 | { |
||
510 | $formClass = AddProductForm::class; |
||
511 | $this->extend('updateFormClass', $formClass); |
||
512 | return $formClass; |
||
513 | } |
||
514 | } |
||
515 |
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