Skip to content

Commit ab9bdf5

Browse files
committed
POST /product/specific-price
1 parent 66f06b2 commit ab9bdf5

File tree

7 files changed

+1233
-0
lines changed

7 files changed

+1233
-0
lines changed

config/admin/services.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,19 @@ services:
4141
tags:
4242
- { name: 'serializer.normalizer', priority: 100 }
4343

44+
PrestaShop\Module\APIResources\ApiPlatform\Normalizer\DateTimeImmutableNormalizer:
45+
tags:
46+
- { name: 'serializer.normalizer', priority: 100 }
47+
4448
PrestaShopBundle\ApiPlatform\Serializer\CQRSApiSerializer:
4549
class: PrestaShop\Module\APIResources\Serializer\QueryParameterTypeCastSerializer
4650
decorates: 'api_platform.serializer'
4751
autowire: true
4852
arguments:
4953
$decorated: '@.inner'
54+
55+
PrestaShop\Module\APIResources\ApiPlatform\Provider\SpecificPriceListProvider:
56+
autowire: true
57+
public: true
58+
tags:
59+
- { name: 'api_platform.state_provider' }
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
/**
4+
* Copyright since 2007 PrestaShop SA and Contributors
5+
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
6+
*
7+
* NOTICE OF LICENSE
8+
*
9+
* This source file is subject to the Academic Free License version 3.0
10+
* that is bundled with this package in the file LICENSE.md.
11+
* It is also available through the world-wide-web at this URL:
12+
* https://opensource.org/licenses/AFL-3.0
13+
* If you did not receive a copy of the license and are unable to
14+
* obtain it through the world-wide-web, please send an email
15+
* to [email protected] so we can send you a copy immediately.
16+
*
17+
* @author PrestaShop SA and Contributors <[email protected]>
18+
* @copyright Since 2007 PrestaShop SA and Contributors
19+
* @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0
20+
*/
21+
22+
declare(strict_types=1);
23+
24+
namespace PrestaShop\Module\APIResources\ApiPlatform\Normalizer;
25+
26+
use DateTimeImmutable;
27+
use DateTimeInterface;
28+
use PrestaShop\PrestaShop\Core\Util\DateTime\DateTime as DateTimeUtil;
29+
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
30+
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
31+
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
32+
33+
/**
34+
* Custom normalizer for DateTimeImmutable and DateTimeInterface that handles '0000-00-00 00:00:00' as NullDateTime.
35+
* This normalizer has higher priority than the core one (priority 100) to handle unlimited dates correctly.
36+
* Registered in config/admin/services.yml
37+
*
38+
* This matches the behavior in CommandBuilder::castValue() for TYPE_DATETIME which uses DateTime::buildNullableDateTime()
39+
*/
40+
class DateTimeImmutableNormalizer implements DenormalizerInterface, NormalizerInterface
41+
{
42+
public function denormalize($data, string $type, ?string $format = null, array $context = [])
43+
{
44+
// Normalize ISO 8601 format to standard datetime format for null date detection
45+
// '0000-00-00T00:00:00+00:00' should be treated as '0000-00-00 00:00:00'
46+
if (is_string($data) && strpos($data, '0000-00-00') === 0) {
47+
// Extract just the date part (YYYY-MM-DD) and time part if present
48+
// Handle formats like: '0000-00-00T00:00:00+00:00', '0000-00-00T00:00:00Z', etc.
49+
if (preg_match('/^0000-00-00(?:T00:00:00.*)?$/', $data)) {
50+
$data = DateTimeUtil::NULL_DATETIME;
51+
} elseif (preg_match('/^0000-00-00$/', $data)) {
52+
$data = DateTimeUtil::NULL_DATE;
53+
}
54+
}
55+
56+
// Use buildNullableDateTime to handle '0000-00-00 00:00:00' correctly
57+
// This matches the behavior in CommandBuilder::castValue() for TYPE_DATETIME
58+
return DateTimeUtil::buildNullableDateTime($data);
59+
}
60+
61+
public function supportsDenormalization($data, string $type, ?string $format = null)
62+
{
63+
// Support both DateTimeImmutable and DateTimeInterface (which is the type used in command constructors)
64+
return \DateTimeImmutable::class === $type || \DateTimeInterface::class === $type;
65+
}
66+
67+
public function normalize(mixed $object, ?string $format = null, array $context = [])
68+
{
69+
if (!($object instanceof \DateTimeImmutable)) {
70+
throw new InvalidArgumentException('Expected object to be a ' . \DateTimeImmutable::class);
71+
}
72+
73+
return $object->format(DateTimeUtil::DEFAULT_DATETIME_FORMAT);
74+
}
75+
76+
public function supportsNormalization(mixed $data, ?string $format = null)
77+
{
78+
return $data instanceof \DateTimeImmutable;
79+
}
80+
81+
public function getSupportedTypes(?string $format): array
82+
{
83+
return [
84+
\DateTimeImmutable::class => true,
85+
\DateTimeInterface::class => true,
86+
];
87+
}
88+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
/**
4+
* Copyright since 2007 PrestaShop SA and Contributors
5+
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
6+
*
7+
* NOTICE OF LICENSE
8+
*
9+
* This source file is subject to the Academic Free License version 3.0
10+
* that is bundled with this package in the file LICENSE.md.
11+
* It is also available through the world-wide-web at this URL:
12+
* https://opensource.org/licenses/AFL-3.0
13+
* If you did not receive a copy of the license and are unable to
14+
* obtain it through the world-wide-web, please send an email
15+
* to [email protected] so we can send you a copy immediately.
16+
*
17+
* @author PrestaShop SA and Contributors <[email protected]>
18+
* @copyright Since 2007 PrestaShop SA and Contributors
19+
* @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0
20+
*/
21+
22+
declare(strict_types=1);
23+
24+
namespace PrestaShop\Module\APIResources\ApiPlatform\Provider;
25+
26+
use ApiPlatform\Metadata\Operation;
27+
use PrestaShop\PrestaShop\Core\Domain\Product\SpecificPrice\QueryResult\SpecificPriceList;
28+
use PrestaShopBundle\ApiPlatform\Exception\CQRSQueryNotFoundException;
29+
use PrestaShopBundle\ApiPlatform\NormalizationMapper;
30+
use PrestaShopBundle\ApiPlatform\Provider\QueryProvider;
31+
32+
/**
33+
* Custom provider for SpecificPriceList that extracts the specificPrices array
34+
* from the SpecificPriceList object before denormalization
35+
*/
36+
class SpecificPriceListProvider extends QueryProvider
37+
{
38+
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|object|null
39+
{
40+
$CQRSQueryClass = $this->getCQRSQueryClass($operation);
41+
if (null === $CQRSQueryClass) {
42+
throw new CQRSQueryNotFoundException(sprintf('Resource %s has no CQRS query defined.', $operation->getClass()));
43+
}
44+
45+
$filters = $context['filters'] ?? [];
46+
$queryParameters = array_merge($uriVariables, $filters, $this->contextParametersProvider->getContextParameters());
47+
48+
$CQRSQuery = $this->domainSerializer->denormalize($queryParameters, $CQRSQueryClass, null, [NormalizationMapper::NORMALIZATION_MAPPING => $this->getCQRSQueryMapping($operation)]);
49+
$CQRSQueryResult = $this->queryBus->handle($CQRSQuery);
50+
51+
// If the result is a SpecificPriceList object, extract the specificPrices array
52+
if ($CQRSQueryResult instanceof SpecificPriceList) {
53+
$CQRSQueryResult = $CQRSQueryResult->getSpecificPrices();
54+
}
55+
56+
// The result may be null (for DELETE action for example)
57+
if (null === $CQRSQueryResult) {
58+
return new ($operation->getClass())();
59+
}
60+
61+
$denormalizedResult = $this->denormalizeQueryResult($CQRSQueryResult, $operation);
62+
63+
// Add productId from uriVariables to each item in the collection
64+
// This ensures consistency with POST/PATCH responses that include productId
65+
if (is_array($denormalizedResult) && isset($uriVariables['productId'])) {
66+
foreach ($denormalizedResult as $key => $item) {
67+
if (is_object($item)) {
68+
$item->productId = (int) $uriVariables['productId'];
69+
} elseif (is_array($item)) {
70+
$denormalizedResult[$key]['productId'] = (int) $uriVariables['productId'];
71+
}
72+
}
73+
}
74+
75+
return $denormalizedResult;
76+
}
77+
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
<?php
2+
3+
/**
4+
* Copyright since 2007 PrestaShop SA and Contributors
5+
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
6+
*
7+
* NOTICE OF LICENSE
8+
*
9+
* This source file is subject to the Academic Free License version 3.0
10+
* that is bundled with this package in the file LICENSE.md.
11+
* It is also available through the world-wide-web at this URL:
12+
* https://opensource.org/licenses/AFL-3.0
13+
* If you did not receive a copy of the license and are unable to
14+
* obtain it through the world-wide-web, please send an email
15+
* to [email protected] so we can send you a copy immediately.
16+
*
17+
* @author PrestaShop SA and Contributors <[email protected]>
18+
* @copyright Since 2007 PrestaShop SA and Contributors
19+
* @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0
20+
*/
21+
22+
declare(strict_types=1);
23+
24+
namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Product;
25+
26+
use ApiPlatform\Metadata\ApiProperty;
27+
use ApiPlatform\Metadata\ApiResource;
28+
use PrestaShop\Decimal\DecimalNumber;
29+
use PrestaShop\PrestaShop\Core\Domain\Product\Exception\ProductNotFoundException;
30+
use PrestaShop\PrestaShop\Core\Domain\Product\SpecificPrice\Command\AddSpecificPriceCommand;
31+
use PrestaShop\PrestaShop\Core\Domain\Product\SpecificPrice\Command\DeleteSpecificPriceCommand;
32+
use PrestaShop\PrestaShop\Core\Domain\Product\SpecificPrice\Command\EditSpecificPriceCommand;
33+
use PrestaShop\PrestaShop\Core\Domain\Product\SpecificPrice\Exception\SpecificPriceException;
34+
use PrestaShop\PrestaShop\Core\Domain\Product\SpecificPrice\Exception\SpecificPriceNotFoundException;
35+
use PrestaShop\PrestaShop\Core\Domain\Product\SpecificPrice\Query\GetSpecificPriceForEditing;
36+
use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate;
37+
use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete;
38+
use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet;
39+
use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate;
40+
use Symfony\Component\HttpFoundation\Response;
41+
42+
#[ApiResource(
43+
operations: [
44+
new CQRSGet(
45+
uriTemplate: '/products/specific-prices/{specificPriceId}',
46+
CQRSQuery: GetSpecificPriceForEditing::class,
47+
scopes: [
48+
'product_read',
49+
],
50+
CQRSQueryMapping: SpecificPrice::QUERY_MAPPING,
51+
),
52+
new CQRSCreate(
53+
uriTemplate: '/products/specific-prices',
54+
CQRSCommand: AddSpecificPriceCommand::class,
55+
CQRSQuery: GetSpecificPriceForEditing::class,
56+
scopes: [
57+
'product_write',
58+
],
59+
CQRSQueryMapping: SpecificPrice::QUERY_MAPPING,
60+
CQRSCommandMapping: SpecificPrice::CREATE_COMMAND_MAPPING,
61+
),
62+
new CQRSPartialUpdate(
63+
uriTemplate: '/products/specific-prices/{specificPriceId}',
64+
CQRSCommand: EditSpecificPriceCommand::class,
65+
CQRSQuery: GetSpecificPriceForEditing::class,
66+
scopes: [
67+
'product_write',
68+
],
69+
CQRSQueryMapping: SpecificPrice::QUERY_MAPPING,
70+
CQRSCommandMapping: SpecificPrice::UPDATE_COMMAND_MAPPING,
71+
),
72+
new CQRSDelete(
73+
uriTemplate: '/products/specific-prices/{specificPriceId}',
74+
CQRSCommand: DeleteSpecificPriceCommand::class,
75+
scopes: [
76+
'product_write',
77+
],
78+
CQRSCommandMapping: SpecificPrice::DELETE_COMMAND_MAPPING,
79+
),
80+
],
81+
exceptionToStatus: [
82+
ProductNotFoundException::class => Response::HTTP_NOT_FOUND,
83+
SpecificPriceNotFoundException::class => Response::HTTP_NOT_FOUND,
84+
SpecificPriceException::class => Response::HTTP_UNPROCESSABLE_ENTITY,
85+
],
86+
)]
87+
class SpecificPrice
88+
{
89+
#[ApiProperty(identifier: true)]
90+
public int $specificPriceId;
91+
92+
public int $productId;
93+
94+
public string $reductionType;
95+
96+
public DecimalNumber $reductionValue;
97+
98+
public bool $includesTax;
99+
100+
public ?DecimalNumber $fixedPrice = null;
101+
102+
public int $fromQuantity;
103+
104+
public ?\DateTimeImmutable $dateTimeFrom = null;
105+
106+
public ?\DateTimeImmutable $dateTimeTo = null;
107+
108+
public ?int $combinationId = null;
109+
110+
public ?int $shopId = null;
111+
112+
public ?int $currencyId = null;
113+
114+
public ?int $countryId = null;
115+
116+
public ?int $groupId = null;
117+
118+
public ?int $customerId = null;
119+
120+
public ?array $customerInfo = null;
121+
122+
public const QUERY_MAPPING = [
123+
'[specificPriceId]' => '[specificPriceId]',
124+
'[reductionType]' => '[reductionType]',
125+
// Map reductionAmount from QueryResult to reductionValue in API Resource
126+
'[reductionAmount]' => '[reductionValue]',
127+
'[includesTax]' => '[includesTax]',
128+
'[fixedPrice][value]' => '[fixedPrice]',
129+
'[fromQuantity]' => '[fromQuantity]',
130+
'[dateTimeFrom]' => '[dateTimeFrom]',
131+
'[dateTimeTo]' => '[dateTimeTo]',
132+
'[productId]' => '[productId]',
133+
'[customerInfo]' => '[customerInfo]',
134+
'[combinationId]' => '[combinationId]',
135+
'[shopId]' => '[shopId]',
136+
'[currencyId]' => '[currencyId]',
137+
'[countryId]' => '[countryId]',
138+
'[groupId]' => '[groupId]',
139+
];
140+
141+
public const CREATE_COMMAND_MAPPING = [
142+
'[productId]' => '[productId]',
143+
'[reductionType]' => '[reductionType]',
144+
'[reductionValue]' => '[reductionValue]',
145+
'[includeTax]' => '[includeTax]',
146+
'[fixedPrice]' => '[fixedPrice]',
147+
'[fromQuantity]' => '[fromQuantity]',
148+
'[dateTimeFrom]' => '[dateTimeFrom]',
149+
'[dateTimeTo]' => '[dateTimeTo]',
150+
'[shopId]' => '[shopId]',
151+
'[combinationId]' => '[combinationId]',
152+
'[currencyId]' => '[currencyId]',
153+
'[countryId]' => '[countryId]',
154+
'[groupId]' => '[groupId]',
155+
'[customerId]' => '[customerId]',
156+
];
157+
158+
public const UPDATE_COMMAND_MAPPING = [
159+
'[specificPriceId]' => '[specificPriceId]',
160+
// EditSpecificPriceCommand::setReduction() expects 2 args: reductionType and reductionValue
161+
'[reductionType]' => '[reduction][reductionType]',
162+
'[reductionValue]' => '[reduction][reductionValue]',
163+
'[includesTax]' => '[includesTax]',
164+
'[fixedPrice]' => '[fixedPrice]',
165+
'[fromQuantity]' => '[fromQuantity]',
166+
'[dateTimeFrom]' => '[dateTimeFrom]',
167+
'[dateTimeTo]' => '[dateTimeTo]',
168+
'[shopId]' => '[shopId]',
169+
'[combinationId]' => '[combinationId]',
170+
'[currencyId]' => '[currencyId]',
171+
'[countryId]' => '[countryId]',
172+
'[groupId]' => '[groupId]',
173+
'[customerId]' => '[customerId]',
174+
];
175+
176+
public const DELETE_COMMAND_MAPPING = [
177+
'[specificPriceId]' => '[specificPriceId]',
178+
];
179+
}

0 commit comments

Comments
 (0)