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;