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:
2025-11-24 14:08:56 +03:00
committed by Nikita Kiselev
parent b39a344a7d
commit 952d8e58da
18 changed files with 489 additions and 172 deletions

View File

@@ -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();

View File

@@ -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;
}
},

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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())

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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();
}
}

View File

@@ -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'],

View File

@@ -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);
}
};

View File

@@ -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);
}
};

View File

@@ -66,4 +66,9 @@ class Utils
{
return $needle === '' || strpos($haystack, $needle) !== false;
}
public static function boolToInt(bool $value): int
{
return $value === true ? 1 : 0;
}
}

View File

@@ -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]);
}
}

View File

@@ -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();
}
}

View File

@@ -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'],
]);
}
}
}
}

View File

@@ -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);
}
}

View File

@@ -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');
}
}

View File

@@ -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);