feat(customers): track order meta and OC sync
- add telecart_order_meta table and orders_count column for customers - introduce OcCustomerService and OrderMetaService for syncing OC data - rework OrderCreateService transaction flow, metadata handling and tests - increment telegram customer orders_count and expose it via handlers/UI - update stats dashboard with rub formatting, tooltips and customers count - sync SPA theme colors with Telegram WebApp and fix dark variant behavior - add helpers for RUB formatting and bool casting; simplify logs handler
This commit is contained in:
@@ -17,28 +17,41 @@
|
||||
</div>
|
||||
<div class="tw:flex tw:items-center tw:flex-wrap tw:gap-8">
|
||||
<div>
|
||||
<span class="tw:text-surface-500 tw:dark:text-surface-300">Количество заказов</span>
|
||||
<span
|
||||
v-tooltip.top="'Общее количество заказов, сделанное через TeleCart за всё время.'"
|
||||
class="tw:text-surface-500 tw:dark:text-surface-300"
|
||||
>
|
||||
Количество заказов
|
||||
</span>
|
||||
<div
|
||||
class="tw:text-surface-700 tw:dark:text-surface-100 tw:mt-1 tw:text-sm tw:font-semibold">
|
||||
class="tw:text-surface-700 tw:dark:text-surface-100 tw:mt-1 tw:text-sm tw:font-semibold"
|
||||
>
|
||||
{{ stats.items.orders_count ?? '-' }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="tw:text-surface-500 tw:dark:text-surface-300">Общая сумма</span>
|
||||
<span
|
||||
v-tooltip.top="'Итоговая сумма заказов, сделанных через TeleCart за всё время.'"
|
||||
class="tw:text-surface-500 tw:dark:text-surface-300"
|
||||
>Общая сумма</span>
|
||||
<div
|
||||
class="tw:text-surface-700 tw:dark:text-surface-100 tw:mt-1 tw:text-sm tw:font-semibold">
|
||||
{{ stats.items.orders_total_amount ?? '-' }}
|
||||
{{ rub(stats.items.orders_total_amount ?? 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="tw:text-surface-500 tw:dark:text-surface-300">Уникальные товары</span>
|
||||
<span
|
||||
v-tooltip.top="'Общее количество уникальных Telegram-посетителей, взаимодействовавших с магазином за всё время — включая тех, кто просто заходил посмотреть, без оформления заказа.'"
|
||||
class="tw:text-surface-500 tw:dark:text-surface-300">Кол-во посетителей</span>
|
||||
<div
|
||||
class="tw:text-surface-700 tw:dark:text-surface-100 tw:mt-1 tw:text-sm tw:font-semibold">
|
||||
{{ stats.items.order_products_count ?? '-' }}
|
||||
<RouterLink to="/customers">{{ stats.items.customers_count ?? 0 }}</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="tw:text-surface-500 tw:dark:text-surface-300">Статус магазина</span>
|
||||
<span
|
||||
v-tooltip.top="'Текущий статус магазина'"
|
||||
class="tw:text-surface-500 tw:dark:text-surface-300">Статус магазина</span>
|
||||
<div
|
||||
class="tw:text-surface-700 tw:dark:text-surface-100 tw:mt-1 tw:text-sm tw:font-semibold">
|
||||
<div v-if="settings.items.app.app_enabled" class="tw:flex tw:items-center">
|
||||
@@ -102,6 +115,7 @@ import OcImagePicker from "@/components/OcImagePicker.vue";
|
||||
import {apiGet} from "@/utils/http.js";
|
||||
import ResetCacheBtn from "@/components/Form/ResetCacheBtn.vue";
|
||||
import {Button, ButtonGroup} from "primevue";
|
||||
import {rub} from "@/utils/helpers.js";
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const stats = useStatsStore();
|
||||
|
||||
@@ -6,7 +6,7 @@ export const useStatsStore = defineStore('stats', {
|
||||
items: {
|
||||
orders_count: null,
|
||||
orders_total_amount: null,
|
||||
order_products_count: null,
|
||||
customers_count: null,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -15,7 +15,7 @@ export const useStatsStore = defineStore('stats', {
|
||||
const response = await apiPost('getDashboardStats');
|
||||
this.items.orders_count = response.data?.data?.orders_count;
|
||||
this.items.orders_total_amount = response.data?.data?.orders_total_amount;
|
||||
this.items.order_products_count = response.data?.data?.order_products_count;
|
||||
this.items.customers_count = response.data?.data?.customers_count;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -5,3 +5,11 @@ export function getThumb(imageUrl) {
|
||||
const filename = imageUrl.substring(0, extIndex);
|
||||
return `/image/cache/${filename}-100x100${ext}`;
|
||||
}
|
||||
|
||||
export function rub(value) {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
maximumFractionDigits: 0
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
@@ -140,6 +140,9 @@
|
||||
<i v-if="data.is_premium" class="fa fa-star" v-tooltip.top="'Премиум пользователь'"></i>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'orders_count'">
|
||||
<span>{{ data.orders_count }}</span>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'oc_customer_id'">
|
||||
<span v-if="data.oc_customer_id">{{ data.oc_customer_id }}</span>
|
||||
<span v-else>—</span>
|
||||
@@ -177,9 +180,12 @@
|
||||
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по фамилии"
|
||||
class="p-column-filter"/>
|
||||
</template>
|
||||
<template v-else-if="['last_seen_at', 'created_at'].includes(col.field)">
|
||||
<template v-else-if="['last_seen_at', 'created_at', 'privacy_consented_at'].includes(col.field)">
|
||||
<DatePicker v-model="filterModel.value" dateFormat="dd.mm.yy" placeholder="dd.mm.yyyy"/>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'orders_count'">
|
||||
<InputNumber v-model="filterModel.value"/>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'is_premium'">
|
||||
<Dropdown
|
||||
v-model="filterModel.value"
|
||||
@@ -286,6 +292,7 @@ import Textarea from 'primevue/textarea';
|
||||
import OverlayPanel from 'primevue/overlaypanel';
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import Button from 'primevue/button';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import {apiPost} from '@/utils/http.js';
|
||||
import {IconField, InputIcon, useToast} from 'primevue';
|
||||
|
||||
@@ -317,6 +324,15 @@ const columns = ref([
|
||||
filterable: false,
|
||||
visible: false
|
||||
},
|
||||
{
|
||||
field: 'orders_count',
|
||||
header: 'Кол-во заказов',
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
dataType: 'numeric',
|
||||
visible: true,
|
||||
help: 'Общее количество Telegram заказов за всё время',
|
||||
},
|
||||
{field: 'is_premium', header: 'Премиум статус', sortable: true, filterable: true, visible: true},
|
||||
{
|
||||
field: 'oc_customer_id',
|
||||
@@ -418,6 +434,10 @@ const filters = ref({
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
|
||||
},
|
||||
orders_count: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.EQUALS}],
|
||||
},
|
||||
is_premium: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.EQUALS}]
|
||||
@@ -430,13 +450,17 @@ const filters = ref({
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||
},
|
||||
privacy_consented_at: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||
},
|
||||
});
|
||||
|
||||
function processFiltersForBackend(filtersObj) {
|
||||
const processed = JSON.parse(JSON.stringify(filtersObj));
|
||||
|
||||
// Обрабатываем фильтры по датам
|
||||
const dateFields = ['created_at', 'last_seen_at'];
|
||||
const dateFields = ['created_at', 'last_seen_at', 'privacy_consented_at'];
|
||||
dateFields.forEach(field => {
|
||||
if (processed[field] && processed[field].constraints) {
|
||||
processed[field].constraints.forEach(constraint => {
|
||||
@@ -557,6 +581,10 @@ function resetFilters() {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
|
||||
},
|
||||
orders_count: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.EQUALS}]
|
||||
},
|
||||
is_premium: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.EQUALS}]
|
||||
@@ -569,6 +597,10 @@ function resetFilters() {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||
},
|
||||
privacy_consented_at: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||
},
|
||||
};
|
||||
lazyParams.value.page = 1;
|
||||
lazyParams.value.first = 0;
|
||||
|
||||
@@ -38,6 +38,12 @@ const blocks = useBlocksStore();
|
||||
const appLoading = createApp(AppLoading);
|
||||
appLoading.mount('#app');
|
||||
|
||||
function setTelegramUIColors() {
|
||||
const daisyUIBgColor = getCssVarOklchRgb('--color-base-100');
|
||||
window.Telegram.WebApp.setHeaderColor(daisyUIBgColor);
|
||||
window.Telegram.WebApp.setBackgroundColor(daisyUIBgColor);
|
||||
}
|
||||
|
||||
settings.load()
|
||||
.then(() => window.Telegram.WebApp.lockOrientation())
|
||||
.then(() => {
|
||||
@@ -87,18 +93,22 @@ settings.load()
|
||||
if (settings.night_auto) {
|
||||
window.Telegram.WebApp.onEvent('themeChanged', function () {
|
||||
document.documentElement.setAttribute('data-theme', settings.theme[this.colorScheme]);
|
||||
setTelegramUIColors();
|
||||
});
|
||||
}
|
||||
|
||||
const tgColorScheme = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--tg-color-scheme')
|
||||
.trim();
|
||||
if (tgColorScheme) {
|
||||
document.documentElement.classList.add(tgColorScheme);
|
||||
}
|
||||
|
||||
for (const key in settings.theme.variables) {
|
||||
document.documentElement.style.setProperty(key, settings.theme.variables[key]);
|
||||
|
||||
}
|
||||
|
||||
const daisyUIBgColor = getCssVarOklchRgb('--color-base-100');
|
||||
window.Telegram.WebApp.setHeaderColor(daisyUIBgColor);
|
||||
window.Telegram.WebApp.setBackgroundColor(daisyUIBgColor);
|
||||
setTelegramUIColors();
|
||||
}
|
||||
)
|
||||
.then(() => new AppMetaInitializer(settings).init())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@plugin "daisyui" {
|
||||
themes: all;
|
||||
|
||||
@@ -4,17 +4,14 @@ namespace Bastion\Handlers;
|
||||
|
||||
use Openguru\OpenCartFramework\Config\Settings;
|
||||
use Openguru\OpenCartFramework\Http\JsonResponse;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class LogsHandler
|
||||
{
|
||||
private Settings $settings;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(Settings $settings, LoggerInterface $logger)
|
||||
public function __construct(Settings $settings)
|
||||
{
|
||||
$this->settings = $settings;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
public function getLogs(): JsonResponse
|
||||
@@ -37,9 +34,6 @@ class LogsHandler
|
||||
{
|
||||
$parsed = [];
|
||||
|
||||
// Регулярка для формата Monolog с ISO 8601 датой: [YYYY-MM-DDTHH:MM:SS.microseconds+timezone] channel.LEVEL: message {context} [extra]
|
||||
// Пример: [2025-11-23T14:28:21.772518+00:00] TeleCart.ERROR: Invalid Telegram Signature. {"exception":"..."} []
|
||||
// Поддерживает также формат без контекста и extra: [2025-11-23T14:28:21.772518+00:00] TeleCart.INFO: Message text
|
||||
$pattern = '/^\[([^\]]+)\]\s+([^.]+)\.(\w+):\s+(.+)$/s';
|
||||
|
||||
foreach ($lines as $line) {
|
||||
|
||||
@@ -6,7 +6,6 @@ use Openguru\OpenCartFramework\Http\JsonResponse;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Table;
|
||||
|
||||
class StatsHandler
|
||||
{
|
||||
@@ -21,28 +20,32 @@ class StatsHandler
|
||||
{
|
||||
$ordersTotalAmount = $this->builder->newQuery()
|
||||
->select([
|
||||
new RawExpression('COUNT(DISTINCT orders.order_id) AS orders_count'),
|
||||
new RawExpression('COUNT(DISTINCT orders.order_id) AS orders_total_count'),
|
||||
new RawExpression('SUM(orders.total) AS orders_total_amount'),
|
||||
new RawExpression('COUNT(DISTINCT order_product.product_id) AS order_products_count'),
|
||||
])
|
||||
->from(db_table('order'), 'orders')
|
||||
->join(new Table(db_table('order_history'), 'order_history'), function (JoinClause $join) {
|
||||
$join->on('orders.order_id', '=', 'order_history.order_id')
|
||||
->where('order_history.comment', '=', 'Заказ оформлен через Telegram Mini App');
|
||||
})
|
||||
->join(new Table(db_table('order_product'), 'order_product'), function (JoinClause $join) {
|
||||
$join->on('orders.order_id', '=', 'order_product.order_id');
|
||||
->join('telecart_order_meta', function (JoinClause $join) {
|
||||
$join->on('orders.order_id', '=', 'telecart_order_meta.oc_order_id')
|
||||
->whereRaw('orders.store_id = telecart_order_meta.oc_store_id');
|
||||
})
|
||||
->firstOrNull();
|
||||
|
||||
|
||||
if ($ordersTotalAmount) {
|
||||
$data = [
|
||||
'orders_count' => (int) $ordersTotalAmount['orders_count'],
|
||||
'orders_count' => (int) $ordersTotalAmount['orders_total_count'],
|
||||
'orders_total_amount' => (int) $ordersTotalAmount['orders_total_amount'],
|
||||
'order_products_count' => (int) $ordersTotalAmount['order_products_count'],
|
||||
'customers_count' => $this->countCustomersCount(),
|
||||
];
|
||||
}
|
||||
|
||||
return new JsonResponse(compact('data'));
|
||||
}
|
||||
|
||||
private function countCustomersCount(): int
|
||||
{
|
||||
return $this->builder->newQuery()
|
||||
->from('telecart_customers')
|
||||
->count();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ class TelegramCustomersHandler
|
||||
'photo_url',
|
||||
'last_seen_at',
|
||||
'referral',
|
||||
'orders_count',
|
||||
'privacy_consented_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
@@ -254,6 +255,14 @@ class TelegramCustomersHandler
|
||||
$query->where($field, '=', $value);
|
||||
} elseif ($matchMode === 'notEquals') {
|
||||
$query->where($field, '!=', $value);
|
||||
} elseif ($matchMode === 'gt') {
|
||||
$query->where($field, '>', $value);
|
||||
} elseif ($matchMode === 'lt') {
|
||||
$query->where($field, '<', $value);
|
||||
} elseif ($matchMode === 'gte') {
|
||||
$query->where($field, '>=', $value);
|
||||
} elseif ($matchMode === 'lte') {
|
||||
$query->where($field, '<=', $value);
|
||||
} elseif ($matchMode === 'dateIs') {
|
||||
// Для точного совпадения даты используем диапазон от 00:00:00 до 23:59:59
|
||||
$date = date('Y-m-d', strtotime($value));
|
||||
@@ -323,6 +332,7 @@ class TelegramCustomersHandler
|
||||
'photo_url' => $customer['photo_url'],
|
||||
'last_seen_at' => $customer['last_seen_at'],
|
||||
'referral' => $customer['referral'],
|
||||
'orders_count' => (int) $customer['orders_count'],
|
||||
'privacy_consented_at' => $customer['privacy_consented_at'],
|
||||
'created_at' => $customer['created_at'],
|
||||
'updated_at' => $customer['updated_at'],
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Openguru\OpenCartFramework\Migrations\Migration;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$tableName = 'telecart_order_meta';
|
||||
|
||||
$sql = <<<SQL
|
||||
CREATE TABLE IF NOT EXISTS `{$tableName}` (
|
||||
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`oc_order_id` INT(11) UNSIGNED NOT NULL,
|
||||
`oc_store_id` INT(11) UNSIGNED NOT NULL,
|
||||
`telecart_customer_id` INT(11) UNSIGNED DEFAULT NULL,
|
||||
`meta_data` JSON DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `unique_oc_order_id` (`oc_order_id`),
|
||||
KEY `idx_oc_store_id` (`oc_store_id`),
|
||||
KEY `idx_telecart_customer_id` (`telecart_customer_id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB;
|
||||
SQL;
|
||||
|
||||
$this->database->statement($sql);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
use Openguru\OpenCartFramework\Migrations\Migration;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$sql = <<<SQL
|
||||
ALTER TABLE `telecart_customers`
|
||||
ADD COLUMN `orders_count` INT(11) UNSIGNED NOT NULL DEFAULT 0 AFTER `referral`;
|
||||
SQL;
|
||||
|
||||
$this->database->statement($sql);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -66,4 +66,9 @@ class Utils
|
||||
{
|
||||
return $needle === '' || strpos($haystack, $needle) !== false;
|
||||
}
|
||||
|
||||
public static function boolToInt(bool $value): int
|
||||
{
|
||||
return $value === true ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,4 +120,12 @@ class TelegramCustomer
|
||||
'last_seen_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function increase(int $id, string $field): bool
|
||||
{
|
||||
$table = self::TABLE_NAME;
|
||||
$sql = "UPDATE `$table` SET `$field` = `$field` + 1 WHERE id = ?";
|
||||
|
||||
return $this->database->statement($sql, [$id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
|
||||
class OcCustomerService
|
||||
{
|
||||
private Builder $builder;
|
||||
private ConnectionInterface $database;
|
||||
|
||||
public function __construct(Builder $builder, ConnectionInterface $database)
|
||||
{
|
||||
$this->builder = $builder;
|
||||
$this->database = $database;
|
||||
}
|
||||
|
||||
public function findOrCreate(array $orderData): ?int
|
||||
{
|
||||
$email = Arr::get($orderData, 'email');
|
||||
$phone = Arr::get($orderData, 'telephone');
|
||||
|
||||
if (! $email && ! $phone) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$customer = $this->builder->newQuery()
|
||||
->from(db_table('customer'))
|
||||
->where('email', '=', $email)
|
||||
->where('telephone', '=', $phone)
|
||||
->firstOrNull();
|
||||
|
||||
if ($customer) {
|
||||
return (int) $customer['customer_id'];
|
||||
}
|
||||
|
||||
$customerData = [
|
||||
'customer_group_id' => $orderData['customer_group_id'],
|
||||
'store_id' => $orderData['store_id'],
|
||||
'language_id' => $orderData['language_id'],
|
||||
'firstname' => $orderData['firstname'] ?? '',
|
||||
'lastname' => $orderData['lastname'] ?? '',
|
||||
'email' => $orderData['email'] ?? '',
|
||||
'telephone' => $orderData['telephone'] ?? '',
|
||||
'fax' => $orderData['fax'] ?? '',
|
||||
'password' => bin2hex(random_bytes(16)),
|
||||
'salt' => bin2hex(random_bytes(9)),
|
||||
'ip' => $orderData['ip'] ?? '',
|
||||
'status' => 1,
|
||||
'safe' => 0,
|
||||
'token' => bin2hex(random_bytes(32)),
|
||||
'code' => '',
|
||||
'date_added' => $orderData['date_added'],
|
||||
];
|
||||
|
||||
$this->database->insert(db_table('customer'), $customerData);
|
||||
|
||||
return $this->database->lastInsertId();
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,14 @@ namespace App\Services;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
use JsonException;
|
||||
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Openguru\OpenCartFramework\Telegram\TelegramService;
|
||||
use Openguru\OpenCartFramework\Validator\ValidatorInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class OrderCreateService
|
||||
{
|
||||
@@ -22,6 +23,9 @@ class OrderCreateService
|
||||
private SettingsService $settings;
|
||||
private TelegramService $telegramService;
|
||||
private LoggerInterface $logger;
|
||||
private TelegramCustomerService $telegramCustomerService;
|
||||
private OcCustomerService $ocCustomerService;
|
||||
private OrderMetaService $orderMetaService;
|
||||
|
||||
public function __construct(
|
||||
ConnectionInterface $database,
|
||||
@@ -29,7 +33,10 @@ class OrderCreateService
|
||||
OcRegistryDecorator $registry,
|
||||
SettingsService $settings,
|
||||
TelegramService $telegramService,
|
||||
LoggerInterface $logger
|
||||
LoggerInterface $logger,
|
||||
TelegramCustomerService $telegramCustomerService,
|
||||
OcCustomerService $ocCustomerService,
|
||||
OrderMetaService $orderMetaService
|
||||
) {
|
||||
$this->database = $database;
|
||||
$this->cartService = $cartService;
|
||||
@@ -37,8 +44,15 @@ class OrderCreateService
|
||||
$this->settings = $settings;
|
||||
$this->telegramService = $telegramService;
|
||||
$this->logger = $logger;
|
||||
$this->telegramCustomerService = $telegramCustomerService;
|
||||
$this->ocCustomerService = $ocCustomerService;
|
||||
$this->orderMetaService = $orderMetaService;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Throwable
|
||||
* @throws JsonException
|
||||
*/
|
||||
public function create(array $data, array $meta = []): array
|
||||
{
|
||||
$now = Carbon::now();
|
||||
@@ -56,6 +70,15 @@ class OrderCreateService
|
||||
$products = $cart['products'] ?? [];
|
||||
$totals = $cart['totals'] ?? [];
|
||||
|
||||
// Получаем telegram_user_id из tgData
|
||||
$telegramUserId = Arr::get($data['tgData'] ?? [], 'user.id');
|
||||
|
||||
if (! $telegramUserId) {
|
||||
throw new RuntimeException('Telegram user id is required.');
|
||||
}
|
||||
|
||||
$customOrderFields = $this->customOrderFields($data);
|
||||
|
||||
$orderData = [
|
||||
'store_id' => $storeId,
|
||||
'store_name' => $storeName,
|
||||
@@ -83,88 +106,40 @@ class OrderCreateService
|
||||
'customer_group_id' => $customerGroupId,
|
||||
];
|
||||
|
||||
$orderId = null;
|
||||
try {
|
||||
$this->database->beginTransaction();
|
||||
|
||||
$this->database->transaction(
|
||||
function () use (&$orderData, $products, $totals, $orderStatusId, $now, &$orderId, $data) {
|
||||
$success = $this->database->insert(db_table('order'), $orderData);
|
||||
|
||||
if (! $success) {
|
||||
[, $error] = $this->database->getLastError();
|
||||
throw new RuntimeException("Failed to insert row into order. Error: $error");
|
||||
$ocCustomerId = $this->ocCustomerService->findOrCreate($orderData);
|
||||
$telecartCustomerId = null;
|
||||
if ($ocCustomerId) {
|
||||
$telecartCustomerId = $this->telegramCustomerService->assignOcCustomer(
|
||||
$telegramUserId,
|
||||
$ocCustomerId
|
||||
);
|
||||
}
|
||||
|
||||
$this->database->insert(db_table('order'), $orderData);
|
||||
$orderId = $this->database->lastInsertId();
|
||||
|
||||
// Insert products
|
||||
foreach ($products as $product) {
|
||||
$success = $this->database->insert(db_table('order_product'), [
|
||||
'order_id' => $orderId,
|
||||
'product_id' => $product['product_id'],
|
||||
'name' => $product['name'],
|
||||
'model' => $product['model'],
|
||||
'quantity' => $product['quantity'],
|
||||
'price' => $product['price_numeric'],
|
||||
'total' => $product['total_numeric'],
|
||||
'reward' => $product['reward_numeric'],
|
||||
]);
|
||||
|
||||
if (! $success) {
|
||||
[, $error] = $this->database->getLastError();
|
||||
throw new RuntimeException("Failed to insert row into order_product. Error: $error");
|
||||
}
|
||||
|
||||
$orderProductId = $this->database->lastInsertId();
|
||||
foreach ($product['option'] as $option) {
|
||||
$success = $this->database->insert(db_table('order_option'), [
|
||||
'order_id' => $orderId,
|
||||
'order_product_id' => $orderProductId,
|
||||
'product_option_id' => $option['product_option_id'],
|
||||
'product_option_value_id' => $option['product_option_value_id'],
|
||||
'name' => $option['name'],
|
||||
'value' => $option['value'],
|
||||
'type' => $option['type'],
|
||||
]);
|
||||
|
||||
if (! $success) {
|
||||
[, $error] = $this->database->getLastError();
|
||||
throw new RuntimeException("Failed to insert row into order_option. Error: $error");
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->insertProducts($products, $orderId);
|
||||
|
||||
// Insert totals
|
||||
foreach ($totals as $total) {
|
||||
$success = $this->database->insert(db_table('order_total'), [
|
||||
'order_id' => $orderId,
|
||||
'code' => $total['code'],
|
||||
'title' => $total['title'],
|
||||
'value' => $total['value'],
|
||||
'sort_order' => $total['sort_order'],
|
||||
]);
|
||||
|
||||
if (! $success) {
|
||||
[, $error] = $this->database->getLastError();
|
||||
throw new RuntimeException("Failed to insert row into order_total. Error: $error");
|
||||
}
|
||||
}
|
||||
$this->insertTotals($totals, $orderId);
|
||||
|
||||
// Insert history
|
||||
$history = [
|
||||
'order_id' => $orderId,
|
||||
'order_status_id' => $orderStatusId,
|
||||
'notify' => 0,
|
||||
'comment' => $this->formatHistoryComment($data),
|
||||
'date_added' => $now,
|
||||
];
|
||||
$success = $this->database->insert(db_table('order_history'), $history);
|
||||
$this->insertHistory($orderId, $orderStatusId, $customOrderFields, $now);
|
||||
|
||||
if (! $success) {
|
||||
[, $error] = $this->database->getLastError();
|
||||
throw new RuntimeException("Failed to insert row into order_history. Error: $error");
|
||||
// Insert order meta data
|
||||
if ($customOrderFields) {
|
||||
$this->orderMetaService->insert($orderId, $storeId, $customOrderFields, $telecartCustomerId);
|
||||
}
|
||||
|
||||
$this->database->commitTransaction();
|
||||
} catch (Throwable $exception) {
|
||||
$this->database->rollBackTransaction();
|
||||
throw $exception;
|
||||
}
|
||||
);
|
||||
|
||||
$this->cartService->flush();
|
||||
|
||||
@@ -174,6 +149,10 @@ class OrderCreateService
|
||||
|
||||
$this->sendNotifications($orderData, $data['tgData']);
|
||||
|
||||
if ($telecartCustomerId) {
|
||||
$this->telegramCustomerService->increaseOrdersCount($telecartCustomerId);
|
||||
}
|
||||
|
||||
$dateTimeFormatted = '';
|
||||
try {
|
||||
$dateTimeFormatted = $now->format('d.m.Y H:i');
|
||||
@@ -212,8 +191,8 @@ class OrderCreateService
|
||||
if ($chatId && $template) {
|
||||
$message = $this->telegramService->prepareMessage($template, $variables);
|
||||
try {
|
||||
$this->telegramService->sendMessage((int) $chatId, $message);
|
||||
} catch (Exception $exception) {
|
||||
$this->telegramService->sendMessage($chatId, $message);
|
||||
} catch (Throwable $exception) {
|
||||
$this->logger->error(
|
||||
"Telegram sendMessage to owner error. ChatID: $chatId, Message: $message",
|
||||
['exception' => $exception],
|
||||
@@ -229,7 +208,7 @@ class OrderCreateService
|
||||
$message = $this->telegramService->prepareMessage($template, $variables);
|
||||
try {
|
||||
$this->telegramService->sendMessage($customerChatId, $message);
|
||||
} catch (Exception $exception) {
|
||||
} catch (Throwable $exception) {
|
||||
$this->logger->error(
|
||||
"Telegram sendMessage to customer error. ChatID: $chatId, Message: $message",
|
||||
['exception' => $exception]
|
||||
@@ -238,9 +217,22 @@ class OrderCreateService
|
||||
}
|
||||
}
|
||||
|
||||
private function formatHistoryComment(array $data): string
|
||||
private function formatHistoryComment(array $customFields): string
|
||||
{
|
||||
$customFields = Arr::except($data, [
|
||||
$additionalString = '';
|
||||
if ($customFields) {
|
||||
$additionalString = "\n\nДополнительная информация по заказу:\n";
|
||||
foreach ($customFields as $field => $value) {
|
||||
$additionalString .= $field . ': ' . $value . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
return "Заказ оформлен через Telegram Mini App.$additionalString";
|
||||
}
|
||||
|
||||
private function customOrderFields(array $data): array
|
||||
{
|
||||
return Arr::except($data, [
|
||||
'firstname',
|
||||
'lastname',
|
||||
'email',
|
||||
@@ -253,15 +245,67 @@ class OrderCreateService
|
||||
'payment_method',
|
||||
'tgData',
|
||||
]);
|
||||
}
|
||||
|
||||
$additionalString = '';
|
||||
if ($customFields) {
|
||||
$additionalString = "\n\nДополнительная информация по заказу:\n";
|
||||
foreach ($customFields as $field => $value) {
|
||||
$additionalString .= $field . ': ' . $value . "\n";
|
||||
public function insertTotals(array $totals, int $orderId): void
|
||||
{
|
||||
foreach ($totals as $total) {
|
||||
$this->database->insert(db_table('order_total'), [
|
||||
'order_id' => $orderId,
|
||||
'code' => $total['code'],
|
||||
'title' => $total['title'],
|
||||
'value' => $total['value'],
|
||||
'sort_order' => $total['sort_order'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return "Заказ оформлен через Telegram Mini App.{$additionalString}";
|
||||
/**
|
||||
* @param int $orderId
|
||||
* @param int $orderStatusId
|
||||
* @param array $customOrderFields
|
||||
* @param Carbon $now
|
||||
* @return void
|
||||
*/
|
||||
public function insertHistory(int $orderId, int $orderStatusId, array $customOrderFields, Carbon $now): void
|
||||
{
|
||||
$history = [
|
||||
'order_id' => $orderId,
|
||||
'order_status_id' => $orderStatusId,
|
||||
'notify' => 0,
|
||||
'comment' => $this->formatHistoryComment($customOrderFields),
|
||||
'date_added' => $now,
|
||||
];
|
||||
|
||||
$this->database->insert(db_table('order_history'), $history);
|
||||
}
|
||||
|
||||
private function insertProducts($products, int $orderId): void
|
||||
{
|
||||
foreach ($products as $product) {
|
||||
$this->database->insert(db_table('order_product'), [
|
||||
'order_id' => $orderId,
|
||||
'product_id' => $product['product_id'],
|
||||
'name' => $product['name'],
|
||||
'model' => $product['model'],
|
||||
'quantity' => $product['quantity'],
|
||||
'price' => $product['price_numeric'],
|
||||
'total' => $product['total_numeric'],
|
||||
'reward' => $product['reward_numeric'],
|
||||
]);
|
||||
|
||||
$orderProductId = $this->database->lastInsertId();
|
||||
foreach ($product['option'] as $option) {
|
||||
$this->database->insert(db_table('order_option'), [
|
||||
'order_id' => $orderId,
|
||||
'order_product_id' => $orderProductId,
|
||||
'product_option_id' => $option['product_option_id'],
|
||||
'product_option_value_id' => $option['product_option_value_id'],
|
||||
'name' => $option['name'],
|
||||
'value' => $option['value'],
|
||||
'type' => $option['type'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
|
||||
|
||||
class OrderMetaService
|
||||
{
|
||||
private ConnectionInterface $connection;
|
||||
|
||||
public function __construct(ConnectionInterface $connection)
|
||||
{
|
||||
$this->connection = $connection;
|
||||
}
|
||||
|
||||
public function insert(int $orderId, int $storeId, array $fields, ?int $telecartCustomerId = null): void
|
||||
{
|
||||
$orderMeta = [
|
||||
'oc_order_id' => $orderId,
|
||||
'oc_store_id' => $storeId,
|
||||
'telecart_customer_id' => $telecartCustomerId,
|
||||
'meta_data' => json_encode($fields, JSON_THROW_ON_ERROR),
|
||||
];
|
||||
|
||||
$this->connection->insert('telecart_order_meta', $orderMeta);
|
||||
}
|
||||
}
|
||||
@@ -5,21 +5,18 @@ declare(strict_types=1);
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\TelegramCustomer;
|
||||
use Carbon\Carbon;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Openguru\OpenCartFramework\Support\Utils;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Сервис для работы с Telegram-кастомерами
|
||||
*
|
||||
* @package App\Services
|
||||
*/
|
||||
class TelegramCustomerService
|
||||
{
|
||||
private TelegramCustomer $telegramCustomerModel;
|
||||
private TelegramCustomer $telegramCustomer;
|
||||
|
||||
public function __construct(TelegramCustomer $telegramCustomerModel)
|
||||
public function __construct(TelegramCustomer $telegramCustomer)
|
||||
{
|
||||
$this->telegramCustomerModel = $telegramCustomerModel;
|
||||
$this->telegramCustomer = $telegramCustomer;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,12 +31,12 @@ class TelegramCustomerService
|
||||
$telegramUserId = $this->extractTelegramUserId($telegramUserData);
|
||||
$telegramCustomerData = $this->prepareCustomerData($telegramUserData, $telegramUserId);
|
||||
|
||||
$existingRecord = $this->telegramCustomerModel->findByTelegramUserId($telegramUserId);
|
||||
$existingRecord = $this->telegramCustomer->findByTelegramUserId($telegramUserId);
|
||||
|
||||
if ($existingRecord) {
|
||||
$this->telegramCustomerModel->updateByTelegramUserId($telegramUserId, $telegramCustomerData);
|
||||
$this->telegramCustomer->updateByTelegramUserId($telegramUserId, $telegramCustomerData);
|
||||
} else {
|
||||
$this->telegramCustomerModel->create($telegramCustomerData);
|
||||
$this->telegramCustomer->create($telegramCustomerData);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,21 +73,40 @@ class TelegramCustomerService
|
||||
'first_name' => Arr::get($telegramUserData, 'first_name'),
|
||||
'last_name' => Arr::get($telegramUserData, 'last_name'),
|
||||
'language_code' => Arr::get($telegramUserData, 'language_code'),
|
||||
'is_premium' => $this->convertToInt(Arr::get($telegramUserData, 'is_premium', false)),
|
||||
'allows_write_to_pm' => $this->convertToInt(Arr::get($telegramUserData, 'allows_write_to_pm', false)),
|
||||
'is_premium' => Utils::boolToInt(Arr::get($telegramUserData, 'is_premium', false)),
|
||||
'allows_write_to_pm' => Utils::boolToInt(Arr::get($telegramUserData, 'allows_write_to_pm', false)),
|
||||
'photo_url' => Arr::get($telegramUserData, 'photo_url'),
|
||||
'last_seen_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертировать булево значение в int для БД
|
||||
* Assign OpenCart Customer to Telegram User ID and return Telecart Customer ID if it exists.
|
||||
*
|
||||
* @param mixed $value Значение для конвертации
|
||||
* @return int 1 или 0
|
||||
* @param $telegramUserId
|
||||
* @param int $ocCustomerId
|
||||
* @return int|null
|
||||
*/
|
||||
private function convertToInt($value): int
|
||||
public function assignOcCustomer($telegramUserId, int $ocCustomerId): ?int
|
||||
{
|
||||
return ($value === true || $value === 1 || $value === '1') ? 1 : 0;
|
||||
$customer = $this->telegramCustomer->findByTelegramUserId($telegramUserId);
|
||||
|
||||
if (! $customer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($customer['oc_customer_id'] === null) {
|
||||
$this->telegramCustomer->updateByTelegramUserId($telegramUserId, [
|
||||
'oc_customer_id' => $ocCustomerId,
|
||||
'updated_at' => Carbon::now()->toDateTimeString(),
|
||||
]);
|
||||
}
|
||||
|
||||
return (int) $customer['id'];
|
||||
}
|
||||
|
||||
public function increaseOrdersCount(int $telecartCustomerId): void
|
||||
{
|
||||
$this->telegramCustomer->increase($telecartCustomerId, 'orders_count');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Services\CartService;
|
||||
use App\Services\OcCustomerService;
|
||||
use App\Services\OrderCreateService;
|
||||
use App\Services\OrderMetaService;
|
||||
use App\Services\SettingsService;
|
||||
use App\Services\TelegramCustomerService;
|
||||
use Carbon\Carbon;
|
||||
use Mockery as m;
|
||||
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
|
||||
use Openguru\OpenCartFramework\Telegram\TelegramService;
|
||||
use Openguru\OpenCartFramework\Validator\ValidatorInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -18,6 +20,18 @@ class OrderCreateServiceTest extends TestCase
|
||||
{
|
||||
public function testCreateNewOrder(): void
|
||||
{
|
||||
$telegramUserId = 9999;
|
||||
$customOrderFields = [
|
||||
'field_1' => 'кирилица',
|
||||
'field_2' => 'hello',
|
||||
];
|
||||
|
||||
$tgData = [
|
||||
'user' => [
|
||||
'id' => $telegramUserId,
|
||||
],
|
||||
];
|
||||
|
||||
$data = [
|
||||
'firstname' => 'John',
|
||||
'lastname' => 'Doe',
|
||||
@@ -29,16 +43,18 @@ class OrderCreateServiceTest extends TestCase
|
||||
'shipping_zone' => 'Rostov',
|
||||
'shipping_postcode' => 'Rostov',
|
||||
'payment_method' => 'Cash',
|
||||
'field_1' => 'кирилица',
|
||||
'field_2' => 'hello',
|
||||
'tgData' => [],
|
||||
];
|
||||
|
||||
$data = array_merge($data, $customOrderFields);
|
||||
|
||||
$data['tgData'] = $tgData;
|
||||
|
||||
$meta = [
|
||||
'ip' => '127.0.0.1',
|
||||
'user_agent' => 'UnitTests',
|
||||
];
|
||||
|
||||
$storeId = $this->app->getConfigValue('store.oc_store_id');
|
||||
$dateAdded = '2026-01-01 00:00:00';
|
||||
$dateAddedFormatted = '01.01.2026 00:00';
|
||||
Carbon::setTestNow($dateAdded);
|
||||
@@ -50,6 +66,8 @@ class OrderCreateServiceTest extends TestCase
|
||||
$currencyValue = 222;
|
||||
$orderId = 1111;
|
||||
$orderProductId = 223;
|
||||
$ocCustomerId = 333;
|
||||
$telecartCustomerId = 444;
|
||||
|
||||
$product = [
|
||||
'product_id' => 93,
|
||||
@@ -63,14 +81,16 @@ class OrderCreateServiceTest extends TestCase
|
||||
$products = [$product];
|
||||
|
||||
$connection = m::mock(ConnectionInterface::class);
|
||||
$connection->shouldReceive('transaction')->once()->andReturnUsing(fn($c) => $c());
|
||||
$connection->shouldReceive('beginTransaction')->once()->andReturnTrue();
|
||||
$connection->shouldReceive('commitTransaction')->once()->andReturnTrue();
|
||||
// $connection->shouldReceive('rollBackTransaction')->once()->andReturnTrue();
|
||||
$connection->shouldReceive('lastInsertId')->once()->andReturn($orderId)->ordered();
|
||||
$connection->shouldReceive('lastInsertId')->once()->andReturn($orderProductId)->ordered();
|
||||
|
||||
$connection->shouldReceive('insert')->once()->with(
|
||||
db_table('order'),
|
||||
[
|
||||
'store_id' => $this->app->getConfigValue('store.oc_store_id'),
|
||||
'store_id' => $storeId,
|
||||
'store_name' => $this->app->getConfigValue('app.app_name'),
|
||||
'firstname' => $data['firstname'],
|
||||
'lastname' => $data['lastname'],
|
||||
@@ -154,7 +174,24 @@ class OrderCreateServiceTest extends TestCase
|
||||
|
||||
$telegramServiceMock = m::mock(TelegramService::class);
|
||||
$loggerMock = m::mock(LoggerInterface::class);
|
||||
$validatorMock = m::mock(ValidatorInterface::class);
|
||||
|
||||
$telegramCustomerService = m::mock(TelegramCustomerService::class);
|
||||
$telegramCustomerService->shouldReceive('assignOcCustomer')->once()
|
||||
->with($telegramUserId, $ocCustomerId)
|
||||
->andReturn($telecartCustomerId);
|
||||
|
||||
$telegramCustomerService->shouldReceive('increaseOrdersCount')->once()
|
||||
->with($telecartCustomerId)
|
||||
->andReturnNull();
|
||||
|
||||
$ocCustomerService = m::mock(OcCustomerService::class);
|
||||
$ocCustomerService->shouldReceive('findOrCreate')->once()->andReturn($ocCustomerId);
|
||||
|
||||
$orderMetaService = m::mock(OrderMetaService::class);
|
||||
$orderMetaService->shouldReceive('insert')
|
||||
->once()
|
||||
->with($orderId, $storeId, $customOrderFields, $telecartCustomerId)
|
||||
->andReturnNull();
|
||||
|
||||
$service = new OrderCreateService(
|
||||
$connection,
|
||||
@@ -163,7 +200,9 @@ class OrderCreateServiceTest extends TestCase
|
||||
$this->app->get(SettingsService::class),
|
||||
$telegramServiceMock,
|
||||
$loggerMock,
|
||||
$validatorMock,
|
||||
$telegramCustomerService,
|
||||
$ocCustomerService,
|
||||
$orderMetaService
|
||||
);
|
||||
|
||||
$order = $service->create($data, $meta);
|
||||
|
||||
Reference in New Issue
Block a user