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;
|
||||
|
||||
Reference in New Issue
Block a user