Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions core/components/minishop3/lexicon/en/default.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@
$_lang['ms3_err_user_nf'] = 'User not found.';
$_lang['ms3_err_order_nf'] = 'Order with this identifier not found.';
$_lang['ms3_err_order_load'] = 'Error loading order.';
$_lang['ms3_err_order_num_lock'] = 'Could not acquire a lock to generate the order number. Please try again.';
$_lang['ms3_err_order_num_save'] = 'Could not save the order number. Please try again.';
$_lang['ms3_err_status_nf'] = 'Status with this identifier not found.';
$_lang['ms3_err_delivery_nf'] = 'Delivery method with this identifier not found.';
$_lang['ms3_err_payment_nf'] = 'Payment method with this identifier not found.';
Expand Down
2 changes: 2 additions & 0 deletions core/components/minishop3/lexicon/ru/default.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@
$_lang['ms3_err_user_nf'] = 'Пользователь не найден.';
$_lang['ms3_err_order_nf'] = 'Заказ с таким идентификатором не найден.';
$_lang['ms3_err_order_load'] = 'Ошибка при загрузке заказа.';
$_lang['ms3_err_order_num_lock'] = 'Не удалось получить блокировку для генерации номера заказа. Попробуйте ещё раз.';
$_lang['ms3_err_order_num_save'] = 'Не удалось сохранить номер заказа. Попробуйте ещё раз.';
$_lang['ms3_err_status_nf'] = 'Статус с таким идентификатором не найден.';
$_lang['ms3_err_delivery_nf'] = 'Способ доставки с таким идентификатором не найден.';
$_lang['ms3_err_payment_nf'] = 'Способ оплаты с таким идентификатором не найден.';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<?php

declare(strict_types=1);

use Phinx\Migration\AbstractMigration;

/**
* Make ms3_orders.num unique to prevent duplicate order numbers (#380).
*
* Empty draft numbers become NULL (MySQL UNIQUE allows multiple NULLs).
* Existing duplicate non-empty nums are disambiguated before the index is added.
*/
final class AddUniqueIndexOnOrderNum extends AbstractMigration
{
public function up(): void
{
$quoted = $this->quoteIdentifier($this->ordersTable());

$table = $this->table('ms3_orders');
$table->changeColumn('num', 'string', [
'limit' => 20,
'null' => true,
'default' => null,
])->update();

$this->execute("UPDATE {$quoted} SET `num` = NULL WHERE `num` IS NULL OR `num` = ''");

$this->disambiguateDuplicateNums($quoted);
$this->assertNoDuplicateNums($quoted);

if (!$table->hasIndexByName('num')) {
$table->addIndex(['num'], [
'unique' => true,
'name' => 'num',
])->update();
}
}

public function down(): void
{
$table = $this->table('ms3_orders');

if ($table->hasIndexByName('num')) {
$table->removeIndexByName('num')->update();
}

$quoted = $this->quoteIdentifier($this->ordersTable());
$this->execute("UPDATE {$quoted} SET `num` = '' WHERE `num` IS NULL");
}

private function ordersTable(): string
{
$prefix = (string)($this->getAdapter()->getOption('table_prefix') ?? '');

return $prefix . 'ms3_orders';
}

private function disambiguateDuplicateNums(string $quotedTable): void
{
$pdo = $this->getAdapter()->getConnection();
$dupStmt = $pdo->query(
"SELECT `num` FROM {$quotedTable}
WHERE `num` IS NOT NULL AND `num` != ''
GROUP BY `num`
HAVING COUNT(*) > 1"
);

if ($dupStmt === false) {
throw new \RuntimeException('Failed to detect duplicate order numbers before unique index');
}

$select = $pdo->prepare(
"SELECT `id` FROM {$quotedTable} WHERE `num` = ? ORDER BY `id` ASC"
);
$update = $pdo->prepare(
"UPDATE {$quotedTable} SET `num` = ? WHERE `id` = ?"
);
$exists = $pdo->prepare(
"SELECT 1 FROM {$quotedTable} WHERE `num` = ? LIMIT 1"
);

foreach ($dupStmt->fetchAll(\PDO::FETCH_ASSOC) as $row) {
$num = (string)$row['num'];
$select->execute([$num]);
$ids = array_column($select->fetchAll(\PDO::FETCH_ASSOC), 'id');

foreach (array_slice($ids, 1) as $index => $id) {
$newNum = $this->uniqueDisambiguatedNum($exists, $num, (int)$id, $index + 1);
if (!$update->execute([$newNum, (int)$id])) {
throw new \RuntimeException(
"Failed to disambiguate order id={$id} num={$num}"
);
}
}
}
}

/**
* @param \PDOStatement $exists
*/
private function uniqueDisambiguatedNum($exists, string $num, int $id, int $ordinal): string
{
for ($attempt = 0; $attempt < 50; $attempt++) {
$suffix = '-d' . $id . ($attempt > 0 ? 'x' . $attempt : '');
if ($ordinal > 1 && $attempt === 0) {
$suffix = '-d' . $id;
}
$baseLen = max(1, 20 - strlen($suffix));
$candidate = substr($num, 0, $baseLen) . $suffix;
if (strlen($candidate) > 20) {
$candidate = substr($candidate, 0, 20);
}

$exists->execute([$candidate]);
if ($exists->fetchColumn() === false) {
return $candidate;
}
}

throw new \RuntimeException("Unable to allocate unique num for order id={$id}");
}

private function assertNoDuplicateNums(string $quotedTable): void
{
$pdo = $this->getAdapter()->getConnection();
$stmt = $pdo->query(
"SELECT `num` FROM {$quotedTable}
WHERE `num` IS NOT NULL AND `num` != ''
GROUP BY `num`
HAVING COUNT(*) > 1
LIMIT 1"
);

if ($stmt === false) {
throw new \RuntimeException('Failed to verify unique order numbers before index');
}

if ($stmt->fetchColumn() !== false) {
throw new \RuntimeException('Duplicate order numbers remain; refusing to add UNIQUE index');
}
}

private function quoteIdentifier(string $name): string
{
return '`' . str_replace('`', '``', $name) . '`';
}
}
5 changes: 4 additions & 1 deletion core/components/minishop3/schema/minishop3.mysql.schema.xml
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@
<field key="uuid" dbtype="char" precision="36" phptype="string" null="false" default=""/>
<field key="createdon" dbtype="datetime" phptype="datetime" null="true" />
<field key="updatedon" dbtype="datetime" phptype="datetime" null="true" />
<field key="num" dbtype="varchar" precision="20" phptype="string" null="true" default=""/>
<field key="num" dbtype="varchar" precision="20" phptype="string" null="true"/>
<field key="cost" dbtype="decimal" precision="12,2" phptype="float" null="true" default="0"/>
<field key="cart_cost" dbtype="decimal" precision="12,2" phptype="float" null="true" default="0"/>
<field key="delivery_cost" dbtype="decimal" precision="12,2" phptype="float" null="true" default="0"/>
Expand All @@ -268,6 +268,9 @@
<index alias="uuid" name="uuid" primary="false" unique="true" type="BTREE">
<column key="uuid" length="" collation="A" null="false"/>
</index>
<index alias="num" name="num" primary="false" unique="true" type="BTREE">
<column key="num" length="" collation="A" null="true"/>
</index>
<index alias="status_id" name="status_id" primary="false" unique="false" type="BTREE">
<column key="status_id" length="" collation="A" null="false"/>
</index>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -623,8 +623,8 @@ public function create(array $params = []): array
$order->set('payment_id', (int) ($params['payment_id'] ?? 0));
$order->set('order_comment', $params['order_comment'] ?? '');

// Order number will be generated on finalization
$order->set('num', '');
// Order number will be generated on finalization (NULL so UNIQUE allows many drafts)
$order->set('num', null);

// Initial costs (will be recalculated after adding products)
$order->set('cart_cost', 0);
Expand Down Expand Up @@ -748,43 +748,6 @@ public function finalize(array $params = []): array
return Response::success($this->formatOrder($orderData), 'ms3_order_finalized')->getData();
}

/**
* Generate new order number
*
* @return string Order number like "2512/1"
*/
protected function generateOrderNum(): string
{
$format = htmlspecialchars($this->modx->getOption('ms3_order_format_num', null, 'ym'));
$separator = trim(
preg_replace(
"/[^,\/\-]/",
'',
$this->modx->getOption('ms3_order_format_num_separator', null, '/')
)
);
$separator = $separator ?: '/';

$cur = $format ? date($format) : date('ym');

$count = 0;

$c = $this->modx->newQuery(msOrder::class);
$c->where(['num:LIKE' => "{$cur}%"]);
$c->select('num');
$c->sortby('id', 'DESC');
$c->limit(1);
if ($c->prepare() && $c->stmt->execute()) {
$num = $c->stmt->fetchColumn();
if ($num && strpos($num, $separator) !== false) {
[, $count] = explode($separator, $num);
}
}
$count = intval($count) + 1;

return sprintf('%s%s%d', $cur, $separator, $count);
}

/**
* Get status name by ID
*
Expand Down
20 changes: 18 additions & 2 deletions core/components/minishop3/src/Model/mysql/msOrder.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class msOrder extends \MiniShop3\Model\msOrder
'uuid' => '',
'createdon' => null,
'updatedon' => null,
'num' => '',
'num' => null,
'cost' => 0.0,
'cart_cost' => 0.0,
'delivery_cost' => 0.0,
Expand Down Expand Up @@ -85,7 +85,7 @@ class msOrder extends \MiniShop3\Model\msOrder
'precision' => '20',
'phptype' => 'string',
'null' => true,
'default' => '',
'default' => null,
],
'cost' =>
[
Expand Down Expand Up @@ -233,6 +233,22 @@ class msOrder extends \MiniShop3\Model\msOrder
],
],
],
'num' =>
[
'alias' => 'num',
'primary' => false,
'unique' => true,
'type' => 'BTREE',
'columns' =>
[
'num' =>
[
'length' => '',
'collation' => 'A',
'null' => true,
],
],
],
'status_id' =>
[
'alias' => 'status_id',
Expand Down
20 changes: 16 additions & 4 deletions core/components/minishop3/src/ServiceRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ class ServiceRegistry
'class' => \MiniShop3\Services\Order\OrderUserResolver::class,
'interface' => null,
],
'ms3_order_number_generator' => [
'class' => \MiniShop3\Services\Order\OrderNumberGenerator::class,
'interface' => null,
],
'ms3_order_submit_handler' => [
'class' => \MiniShop3\Services\Order\OrderSubmitHandler::class,
'interface' => null,
Expand Down Expand Up @@ -447,7 +451,6 @@ protected function registerService(string $serviceKey, array $config): bool
'ms3_order_cost_calculator',
'ms3_order_user_resolver',
'ms3_order_log',
'ms3_order_finalize',
'ms3_cart_item_manager',
'ms3_customer_address_manager',
];
Expand All @@ -458,6 +461,7 @@ protected function registerService(string $serviceKey, array $config): bool
'ms3_order_address_manager',
'ms3_order_submit_handler',
'ms3_order_status',
'ms3_order_finalize',
];

if (in_array($serviceKey, $controllersWithMs3Only)) {
Expand Down Expand Up @@ -517,27 +521,35 @@ protected function registerServiceWithDependencies(string $serviceKey, string $v
break;

case 'ms3_order_submit_handler':
// OrderSubmitHandler(modX, MiniShop3, OrderDraftManager, OrderCostCalculator,
// OrderFieldManager, OrderAddressManager, OrderUserResolver)
$this->modx->services->add($serviceKey, function () use ($validatedClass, $modx) {
$ms3 = $modx->getService('MiniShop3', \MiniShop3\MiniShop3::class);
$draftManager = $modx->services->get('ms3_order_draft_manager');
$costCalculator = $modx->services->get('ms3_order_cost_calculator');
$fieldManager = $modx->services->get('ms3_order_field_manager');
$addressManager = $modx->services->get('ms3_order_address_manager');
$userResolver = $modx->services->get('ms3_order_user_resolver');
$numberGenerator = $modx->services->get('ms3_order_number_generator');
return new $validatedClass(
$modx,
$ms3,
$draftManager,
$costCalculator,
$fieldManager,
$addressManager,
$userResolver
$userResolver,
$numberGenerator
);
});
break;

case 'ms3_order_finalize':
$this->modx->services->add($serviceKey, function () use ($validatedClass, $modx) {
$ms3 = $modx->getService('MiniShop3', \MiniShop3\MiniShop3::class);
$numberGenerator = $modx->services->get('ms3_order_number_generator');
return new $validatedClass($modx, $ms3, $numberGenerator);
});
break;

case 'ms3_order_status':
// OrderStatusService(modX, MiniShop3, OrderLogService)
$this->modx->services->add($serviceKey, function () use ($validatedClass, $modx) {
Expand Down
Loading