Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21d84a77a9 | |||
| 2172d2ba45 | |||
| 915335aea6 | |||
| 9f791f9f93 | |||
| c31e199e45 | |||
| 42409ddec0 | |||
| d667feda0f | |||
| 6987c8a172 |
@@ -60,11 +60,14 @@ final class AdminPricingTiersController extends Controller
|
||||
/** POST /api/admin/pricing-tiers */
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$todayMsk = Carbon::now('Europe/Moscow')->toDateString();
|
||||
|
||||
$request->validate([
|
||||
'tiers' => ['required', 'array', 'size:7'],
|
||||
'tiers.*.tier_no' => ['required', 'integer', 'between:1,7'],
|
||||
'tiers.*.leads_in_tier' => ['nullable', 'integer', 'min:1'],
|
||||
'tiers.*.price_rub' => ['required', 'numeric', 'min:0'],
|
||||
'effective_from' => ['sometimes', 'date_format:Y-m-d', 'after:'.$todayMsk],
|
||||
]);
|
||||
|
||||
/** @var array<int, array{tier_no:int, leads_in_tier:?int, price_rub:string|float}> $tiers */
|
||||
@@ -89,7 +92,8 @@ final class AdminPricingTiersController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$effectiveFrom = Carbon::now('Europe/Moscow')->startOfMonth()->addMonth()->toDateString();
|
||||
$effectiveFrom = $request->input('effective_from')
|
||||
?? Carbon::now('Europe/Moscow')->startOfMonth()->addMonth()->toDateString();
|
||||
$adminUserId = $this->resolveAdminUserId($request);
|
||||
|
||||
DB::transaction(function () use ($tiers, $effectiveFrom, $adminUserId, $request): void {
|
||||
|
||||
@@ -428,3 +428,69 @@ export async function notifyIncidentRkn(id: number): Promise<ApiAdminIncidentDet
|
||||
);
|
||||
return data.incident;
|
||||
}
|
||||
|
||||
// === SaaS-admin → Тарифная сетка (Plan 4 / Sprint 5C G3) ===
|
||||
|
||||
export interface AdminPricingTier {
|
||||
tier_no: number;
|
||||
leads_in_tier: number | null;
|
||||
price_per_lead_kopecks: number;
|
||||
effective_from: string;
|
||||
}
|
||||
|
||||
export interface PricingTiersResponse {
|
||||
active: AdminPricingTier[];
|
||||
scheduled: Record<string, AdminPricingTier[]>;
|
||||
}
|
||||
|
||||
export interface PricingTierEditorRow {
|
||||
tier_no: number;
|
||||
leads_in_tier: number | null;
|
||||
price_rub: string;
|
||||
}
|
||||
|
||||
export async function getPricingTiers(): Promise<PricingTiersResponse> {
|
||||
const { data } = await apiClient.get<{ data: PricingTiersResponse }>('/api/admin/pricing-tiers');
|
||||
return { active: data.data.active, scheduled: data.data.scheduled ?? {} };
|
||||
}
|
||||
|
||||
export async function createPricingTiers(
|
||||
tiers: PricingTierEditorRow[],
|
||||
effectiveFrom?: string,
|
||||
): Promise<{ effective_from: string }> {
|
||||
await ensureCsrfCookie();
|
||||
const payload: { tiers: PricingTierEditorRow[]; effective_from?: string } = { tiers };
|
||||
if (effectiveFrom) payload.effective_from = effectiveFrom;
|
||||
const { data } = await apiClient.post<{ effective_from: string }>('/api/admin/pricing-tiers', payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteScheduledPricingTier(effectiveFrom: string): Promise<void> {
|
||||
await ensureCsrfCookie();
|
||||
await apiClient.delete(`/api/admin/pricing-tiers/scheduled/${effectiveFrom}`);
|
||||
}
|
||||
|
||||
// === SaaS-admin → Цены поставщиков (Plan 4 / Sprint 5C G3) ===
|
||||
|
||||
export interface AdminSupplier {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
cost_rub: string;
|
||||
quality_score: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export async function getAdminSuppliers(): Promise<AdminSupplier[]> {
|
||||
const { data } = await apiClient.get<{ data: AdminSupplier[] }>('/api/admin/suppliers');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateAdminSupplier(
|
||||
id: number,
|
||||
payload: { cost_rub: string; quality_score: string; is_active: boolean },
|
||||
): Promise<AdminSupplier> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.patch<{ data: AdminSupplier }>(`/api/admin/suppliers/${id}`, payload);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,15 @@ const tariffPriceText = computed(() => {
|
||||
@click="$emit('topup')"
|
||||
>Пополнить</v-btn
|
||||
>
|
||||
<v-btn variant="outlined" prepend-icon="mdi-autorenew" size="small"> Автопополнение </v-btn>
|
||||
<v-tooltip text="Автопополнение будет доступно после подключения платёжного шлюза.">
|
||||
<template #activator="{ props: tipProps }">
|
||||
<span v-bind="tipProps" class="d-inline-flex">
|
||||
<v-btn variant="outlined" prepend-icon="mdi-autorenew" size="small" disabled>
|
||||
Автопополнение
|
||||
</v-btn>
|
||||
</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
@@ -78,7 +86,13 @@ const tariffPriceText = computed(() => {
|
||||
</ul>
|
||||
</template>
|
||||
<div v-else class="tariff-empty mt-2">Тариф не выбран</div>
|
||||
<v-btn variant="outlined" size="small" class="mt-auto">Сменить тариф →</v-btn>
|
||||
<v-tooltip text="Самостоятельная смена тарифа появится после запуска биллинга.">
|
||||
<template #activator="{ props: tipProps }">
|
||||
<span v-bind="tipProps" class="mt-auto d-inline-flex">
|
||||
<v-btn variant="outlined" size="small" disabled>Сменить тариф →</v-btn>
|
||||
</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Мок платежа «в обработке» для pending-баннера BillingView.
|
||||
*
|
||||
* Кошелёк / транзакции / счета подключены к real API (api/billing.ts) в
|
||||
* Sprint 2 Plan C (E3). Pending-баннер — отдельный эпик E4 (Sprint 5);
|
||||
* до его реализации остаётся mock.
|
||||
*/
|
||||
export interface PendingPayment {
|
||||
code: string;
|
||||
amount: number;
|
||||
method: string;
|
||||
startedAt: string;
|
||||
autoCancelAt: string;
|
||||
timeoutMinutes: number;
|
||||
}
|
||||
|
||||
export const MOCK_PENDING: PendingPayment | null = {
|
||||
code: 'TX-89421',
|
||||
amount: 5000,
|
||||
method: 'ЮKassa',
|
||||
startedAt: '14:21',
|
||||
autoCancelAt: '14:51',
|
||||
timeoutMinutes: 30,
|
||||
};
|
||||
@@ -6,9 +6,8 @@
|
||||
* Sprint 2 Plan C (E3): Overview-таб подвязан на real API
|
||||
* (GET /api/billing/wallet → BalanceCard + шапка; TransactionsTable и
|
||||
* InvoicesTable тянут данные сами). Списания — ChargesTab (Plan 4).
|
||||
*
|
||||
* Pending-баннер остаётся mock (MOCK_PENDING) — это отдельный эпик E4
|
||||
* (Sprint 5). TopupDialog «Пополнить баланс» — Task 5 (E1).
|
||||
* Sprint 5C (E4): pending-баннер убран — платёжного шлюза нет (Б-1), реального состояния «платёж в обработке» в БД не существует.
|
||||
* TopupDialog «Пополнить баланс» — Task 5 (E1).
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import BalanceCard from '../components/billing/BalanceCard.vue';
|
||||
@@ -16,7 +15,6 @@ import TransactionsTable from '../components/billing/TransactionsTable.vue';
|
||||
import InvoicesTable from '../components/billing/InvoicesTable.vue';
|
||||
import TopupDialog from '../components/billing/TopupDialog.vue';
|
||||
import ChargesTab from './billing/ChargesTab.vue';
|
||||
import { MOCK_PENDING } from '../composables/mockBilling';
|
||||
import { formatPlain, featureLabel } from '../composables/billingFormatters';
|
||||
import { getWallet, type Wallet } from '../api/billing';
|
||||
import { extractErrorMessage } from '../api/client';
|
||||
@@ -111,19 +109,6 @@ defineExpose({ loadWallet, wallet, topupOpen });
|
||||
</v-alert>
|
||||
|
||||
<template v-else-if="wallet">
|
||||
<v-alert
|
||||
v-if="MOCK_PENDING"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mt-4"
|
||||
role="status"
|
||||
>
|
||||
<strong>1 платёж в обработке</strong> — {{ formatPlain(MOCK_PENDING.amount) }} от
|
||||
{{ MOCK_PENDING.method }}, начат {{ MOCK_PENDING.startedAt }}. Авто-восстановление в
|
||||
{{ MOCK_PENDING.autoCancelAt }} ({{ MOCK_PENDING.timeoutMinutes }} мин).
|
||||
</v-alert>
|
||||
|
||||
<BalanceCard
|
||||
:wallet-rub="walletRub"
|
||||
:leads-balance="leadsBalance"
|
||||
|
||||
@@ -50,14 +50,24 @@
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-btn color="primary" prepend-icon="mdi-pencil" data-testid="open-editor-btn" @click="editorOpen = true">
|
||||
<v-btn color="primary" prepend-icon="mdi-pencil" data-testid="open-editor-btn" @click="openEditor">
|
||||
Редактировать сетку (с {{ nextMonthStart }})
|
||||
</v-btn>
|
||||
|
||||
<v-dialog v-model="editorOpen" max-width="900">
|
||||
<v-card>
|
||||
<v-card-title>Новая сетка (effective_from = {{ nextMonthStart }})</v-card-title>
|
||||
<v-card-title>Новая сетка (effective_from = {{ effectiveFrom }})</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="effectiveFrom"
|
||||
type="date"
|
||||
label="Дата вступления в силу"
|
||||
:min="minEffectiveFrom"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
style="max-width: 240px"
|
||||
data-testid="effective-from-input"
|
||||
/>
|
||||
<table class="editor-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -102,6 +112,21 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="deleteDialogOpen" max-width="440">
|
||||
<v-card>
|
||||
<v-card-title>Удалить запланированный набор?</v-card-title>
|
||||
<v-card-text>
|
||||
Запланированная сетка с <strong>{{ deleteTarget }}</strong> будет удалена.
|
||||
Действие необратимо.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="deleteDialogOpen = false">Отмена</v-btn>
|
||||
<v-btn color="error" data-testid="confirm-delete-btn" @click="performDelete">Удалить</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar
|
||||
v-model="successToastOpen"
|
||||
:timeout="4000"
|
||||
@@ -116,7 +141,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { getPricingTiers, createPricingTiers, deleteScheduledPricingTier, type AdminPricingTier, type PricingTierEditorRow } from '../../api/admin';
|
||||
import { extractErrorMessage } from '../../api/client';
|
||||
|
||||
/**
|
||||
@@ -128,20 +153,8 @@ import { extractErrorMessage } from '../../api/client';
|
||||
* defineExpose ниже — для Vitest unit-тестов.
|
||||
*/
|
||||
|
||||
interface Tier {
|
||||
tier_no: number;
|
||||
leads_in_tier: number | null;
|
||||
price_per_lead_kopecks: number;
|
||||
effective_from: string;
|
||||
}
|
||||
interface EditorRow {
|
||||
tier_no: number;
|
||||
leads_in_tier: number | null;
|
||||
price_rub: string;
|
||||
}
|
||||
|
||||
const active = ref<Tier[]>([]);
|
||||
const scheduled = ref<Record<string, Tier[]>>({});
|
||||
const active = ref<AdminPricingTier[]>([]);
|
||||
const scheduled = ref<Record<string, AdminPricingTier[]>>({});
|
||||
const editorOpen = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
@@ -150,7 +163,11 @@ const errorMessage = ref<string | null>(null);
|
||||
const successMessage = ref<string | null>(null);
|
||||
const successToastOpen = ref(false);
|
||||
|
||||
const defaultEditor: EditorRow[] = [
|
||||
// G10: диалог подтверждения удаления (замена window.confirm).
|
||||
const deleteDialogOpen = ref(false);
|
||||
const deleteTarget = ref<string | null>(null);
|
||||
|
||||
const defaultEditor: PricingTierEditorRow[] = [
|
||||
{ tier_no: 1, leads_in_tier: 100, price_rub: '500.00' },
|
||||
{ tier_no: 2, leads_in_tier: 200, price_rub: '450.00' },
|
||||
{ tier_no: 3, leads_in_tier: 400, price_rub: '400.00' },
|
||||
@@ -159,7 +176,7 @@ const defaultEditor: EditorRow[] = [
|
||||
{ tier_no: 6, leads_in_tier: 3000, price_rub: '270.00' },
|
||||
{ tier_no: 7, leads_in_tier: null, price_rub: '250.00' },
|
||||
];
|
||||
const editor = ref<EditorRow[]>(JSON.parse(JSON.stringify(defaultEditor)));
|
||||
const editor = ref<PricingTierEditorRow[]>(JSON.parse(JSON.stringify(defaultEditor)));
|
||||
|
||||
const tierHeaders = [
|
||||
{ title: '№', key: 'tier_no', sortable: false, width: 80 },
|
||||
@@ -174,13 +191,21 @@ const nextMonthStart = computed(() => {
|
||||
return d.toISOString().slice(0, 10);
|
||||
});
|
||||
|
||||
const effectiveFrom = ref<string>(nextMonthStart.value);
|
||||
|
||||
const minEffectiveFrom = computed(() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
});
|
||||
|
||||
const hasScheduled = computed(() => Object.keys(scheduled.value).length > 0);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
const { data } = await axios.get('/api/admin/pricing-tiers');
|
||||
active.value = data.data.active;
|
||||
scheduled.value = data.data.scheduled || {};
|
||||
const data = await getPricingTiers();
|
||||
active.value = data.active;
|
||||
scheduled.value = data.scheduled;
|
||||
} catch (err) {
|
||||
errorMessage.value = extractErrorMessage(err, 'Не удалось загрузить тарифную сетку.');
|
||||
}
|
||||
@@ -191,9 +216,9 @@ async function submit(): Promise<void> {
|
||||
errorMessage.value = null;
|
||||
successMessage.value = null;
|
||||
try {
|
||||
await axios.post('/api/admin/pricing-tiers', { tiers: editor.value });
|
||||
await createPricingTiers(editor.value, effectiveFrom.value);
|
||||
editorOpen.value = false;
|
||||
successMessage.value = `Сохранено: новая сетка вступит в силу с ${nextMonthStart.value}.`;
|
||||
successMessage.value = `Сохранено: новая сетка вступит в силу с ${effectiveFrom.value}.`;
|
||||
successToastOpen.value = true;
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -204,19 +229,31 @@ async function submit(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete(effectiveFrom: string): Promise<void> {
|
||||
if (!window.confirm(`Удалить запланированный набор с ${effectiveFrom}?`)) {
|
||||
return;
|
||||
}
|
||||
function openEditor(): void {
|
||||
effectiveFrom.value = nextMonthStart.value;
|
||||
editorOpen.value = true;
|
||||
}
|
||||
|
||||
function confirmDelete(effectiveFromDate: string): void {
|
||||
deleteTarget.value = effectiveFromDate;
|
||||
deleteDialogOpen.value = true;
|
||||
}
|
||||
|
||||
async function performDelete(): Promise<void> {
|
||||
const effectiveFromDate = deleteTarget.value;
|
||||
if (effectiveFromDate === null) return;
|
||||
deleteDialogOpen.value = false;
|
||||
errorMessage.value = null;
|
||||
successMessage.value = null;
|
||||
try {
|
||||
await axios.delete(`/api/admin/pricing-tiers/scheduled/${effectiveFrom}`);
|
||||
successMessage.value = `Удалено: запланированный набор с ${effectiveFrom}.`;
|
||||
await deleteScheduledPricingTier(effectiveFromDate);
|
||||
successMessage.value = `Удалено: запланированный набор с ${effectiveFromDate}.`;
|
||||
successToastOpen.value = true;
|
||||
await load();
|
||||
} catch (err) {
|
||||
errorMessage.value = extractErrorMessage(err, 'Не удалось удалить запланированный набор.');
|
||||
} finally {
|
||||
deleteTarget.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +262,9 @@ onMounted(load);
|
||||
defineExpose({
|
||||
load,
|
||||
submit,
|
||||
openEditor,
|
||||
confirmDelete,
|
||||
performDelete,
|
||||
editorOpen,
|
||||
active,
|
||||
scheduled,
|
||||
@@ -234,6 +273,9 @@ defineExpose({
|
||||
successMessage,
|
||||
successToastOpen,
|
||||
saving,
|
||||
effectiveFrom,
|
||||
deleteDialogOpen,
|
||||
deleteTarget,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, reactive } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { getAdminSuppliers, updateAdminSupplier, type AdminSupplier } from '../../api/admin';
|
||||
import { extractErrorMessage } from '../../api/client';
|
||||
|
||||
/**
|
||||
@@ -100,16 +100,7 @@ import { extractErrorMessage } from '../../api/client';
|
||||
* defineExpose ниже — для Vitest unit-тестов.
|
||||
*/
|
||||
|
||||
interface SupplierRow {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
cost_rub: string;
|
||||
quality_score: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
const suppliers = ref<SupplierRow[]>([]);
|
||||
const suppliers = ref<AdminSupplier[]>([]);
|
||||
const saving = reactive<Record<number, boolean>>({});
|
||||
const errorMessages = reactive<Record<number, string>>({});
|
||||
const fetchError = ref<string | null>(null);
|
||||
@@ -128,22 +119,17 @@ const headers = [
|
||||
async function load(): Promise<void> {
|
||||
fetchError.value = null;
|
||||
try {
|
||||
const { data } = await axios.get('/api/admin/suppliers');
|
||||
suppliers.value = data.data;
|
||||
suppliers.value = await getAdminSuppliers();
|
||||
} catch (err) {
|
||||
fetchError.value = extractErrorMessage(err, 'Не удалось загрузить список поставщиков.');
|
||||
}
|
||||
}
|
||||
|
||||
async function save(s: SupplierRow): Promise<void> {
|
||||
async function save(s: AdminSupplier): Promise<void> {
|
||||
saving[s.id] = true;
|
||||
delete errorMessages[s.id]; // очистить предыдущую ошибку перед retry
|
||||
try {
|
||||
await axios.patch(`/api/admin/suppliers/${s.id}`, {
|
||||
cost_rub: s.cost_rub,
|
||||
quality_score: s.quality_score,
|
||||
is_active: s.is_active,
|
||||
});
|
||||
await updateAdminSupplier(s.id, { cost_rub: s.cost_rub, quality_score: s.quality_score, is_active: s.is_active });
|
||||
successToastText.value = `Сохранено: ${s.name} (${s.code}).`;
|
||||
successToastOpen.value = true;
|
||||
} catch (err) {
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
use App\Models\PricingTier;
|
||||
use Database\Seeders\PricingTierSeeder;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
@@ -91,6 +92,60 @@ it('DELETE /scheduled/{effective_from} removes future tiers only', function () {
|
||||
expect(PricingTier::where('effective_from', '1970-01-01')->count())->toBe(7);
|
||||
});
|
||||
|
||||
it('store accepts a custom effective_from date', function (): void {
|
||||
$custom = Carbon::now('Europe/Moscow')->addMonths(3)->toDateString();
|
||||
|
||||
$response = $this->postJson('/api/admin/pricing-tiers', [
|
||||
'tiers' => [
|
||||
['tier_no' => 1, 'leads_in_tier' => 50, 'price_rub' => '600.00'],
|
||||
['tier_no' => 2, 'leads_in_tier' => 150, 'price_rub' => '550.00'],
|
||||
['tier_no' => 3, 'leads_in_tier' => 300, 'price_rub' => '500.00'],
|
||||
['tier_no' => 4, 'leads_in_tier' => 700, 'price_rub' => '450.00'],
|
||||
['tier_no' => 5, 'leads_in_tier' => 1500, 'price_rub' => '400.00'],
|
||||
['tier_no' => 6, 'leads_in_tier' => 3000, 'price_rub' => '350.00'],
|
||||
['tier_no' => 7, 'leads_in_tier' => null, 'price_rub' => '300.00'],
|
||||
],
|
||||
'effective_from' => $custom,
|
||||
]);
|
||||
|
||||
$response->assertCreated()->assertJson(['effective_from' => $custom]);
|
||||
expect(PricingTier::where('effective_from', $custom)->count())->toBe(7);
|
||||
});
|
||||
|
||||
it('store rejects effective_from равную сегодня', function (): void {
|
||||
$today = Carbon::now('Europe/Moscow')->toDateString();
|
||||
|
||||
$this->postJson('/api/admin/pricing-tiers', [
|
||||
'tiers' => [
|
||||
['tier_no' => 1, 'leads_in_tier' => 50, 'price_rub' => '600.00'],
|
||||
['tier_no' => 2, 'leads_in_tier' => 150, 'price_rub' => '550.00'],
|
||||
['tier_no' => 3, 'leads_in_tier' => 300, 'price_rub' => '500.00'],
|
||||
['tier_no' => 4, 'leads_in_tier' => 700, 'price_rub' => '450.00'],
|
||||
['tier_no' => 5, 'leads_in_tier' => 1500, 'price_rub' => '400.00'],
|
||||
['tier_no' => 6, 'leads_in_tier' => 3000, 'price_rub' => '350.00'],
|
||||
['tier_no' => 7, 'leads_in_tier' => null, 'price_rub' => '300.00'],
|
||||
],
|
||||
'effective_from' => $today,
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
it('store rejects effective_from in the past', function (): void {
|
||||
$past = Carbon::now('Europe/Moscow')->subDay()->toDateString();
|
||||
|
||||
$this->postJson('/api/admin/pricing-tiers', [
|
||||
'tiers' => [
|
||||
['tier_no' => 1, 'leads_in_tier' => 50, 'price_rub' => '600.00'],
|
||||
['tier_no' => 2, 'leads_in_tier' => 150, 'price_rub' => '550.00'],
|
||||
['tier_no' => 3, 'leads_in_tier' => 300, 'price_rub' => '500.00'],
|
||||
['tier_no' => 4, 'leads_in_tier' => 700, 'price_rub' => '450.00'],
|
||||
['tier_no' => 5, 'leads_in_tier' => 1500, 'price_rub' => '400.00'],
|
||||
['tier_no' => 6, 'leads_in_tier' => 3000, 'price_rub' => '350.00'],
|
||||
['tier_no' => 7, 'leads_in_tier' => null, 'price_rub' => '300.00'],
|
||||
],
|
||||
'effective_from' => $past,
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
it('writes audit-trail row in saas_admin_audit_log on POST', function () {
|
||||
$this->postJson('/api/admin/pricing-tiers', ['tiers' => [
|
||||
['tier_no' => 1, 'leads_in_tier' => 50, 'price_rub' => '600.00'],
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import axios from 'axios';
|
||||
|
||||
import AdminPricingTiersView from '../../resources/js/views/admin/AdminPricingTiersView.vue';
|
||||
|
||||
vi.mock('axios');
|
||||
/**
|
||||
* Создаёт объект, который проходит `axios.isAxiosError()` (проверяет флаг `isAxiosError: true`),
|
||||
* с нужным `response.data.message`.
|
||||
*/
|
||||
function makeAxiosError(message: string, status = 422): unknown {
|
||||
return Object.assign(new Error(message), {
|
||||
isAxiosError: true,
|
||||
response: { status, data: { message } },
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
|
||||
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
|
||||
return {
|
||||
...orig,
|
||||
getPricingTiers: vi.fn(),
|
||||
createPricingTiers: vi.fn(),
|
||||
deleteScheduledPricingTier: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const adminApi = await import('../../resources/js/api/admin');
|
||||
|
||||
// Auto-импорт компонентов/директив Vuetify подхватывает vite-plugin-vuetify
|
||||
// из vitest.config.ts (см. AdminBillingView.spec.ts).
|
||||
@@ -23,12 +43,9 @@ const mockTiers = [
|
||||
|
||||
describe('AdminPricingTiersView', () => {
|
||||
beforeEach(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.get as any).mockResolvedValue({ data: { data: { active: mockTiers, scheduled: {} } } });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.post as any).mockResolvedValue({ data: { effective_from: '2026-06-01' } });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.delete as any).mockResolvedValue({ data: { ok: true } });
|
||||
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
|
||||
vi.mocked(adminApi.createPricingTiers).mockResolvedValue({ effective_from: '2026-06-01' });
|
||||
vi.mocked(adminApi.deleteScheduledPricingTier).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('renders 7 tier rows from /api/admin/pricing-tiers', async () => {
|
||||
@@ -61,43 +78,80 @@ describe('AdminPricingTiersView', () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (wrapper.vm as any).submit();
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
'/api/admin/pricing-tiers',
|
||||
expect.objectContaining({
|
||||
tiers: expect.arrayContaining([expect.objectContaining({ tier_no: 7, leads_in_tier: null })]),
|
||||
}),
|
||||
expect(adminApi.createPricingTiers).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ tier_no: 7, leads_in_tier: null })]),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('confirmDelete triggers DELETE to /scheduled/{date}', async () => {
|
||||
window.confirm = vi.fn(() => true);
|
||||
it('редактор содержит поле даты effective_from', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, {
|
||||
global: {
|
||||
plugins: [vuetify],
|
||||
stubs: { VDialog: { template: '<div><slot /></div>' } },
|
||||
},
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(wrapper.vm as any).editorOpen = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('[data-testid="effective-from-input"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('submit передаёт выбранную effective_from в createPricingTiers', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (wrapper.vm as any).confirmDelete('2026-06-01');
|
||||
expect(axios.delete).toHaveBeenCalledWith('/api/admin/pricing-tiers/scheduled/2026-06-01');
|
||||
(wrapper.vm as any).effectiveFrom = '2026-09-01';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (wrapper.vm as any).submit();
|
||||
expect(adminApi.createPricingTiers).toHaveBeenCalledWith(expect.any(Array), '2026-09-01');
|
||||
});
|
||||
|
||||
it('confirmDelete открывает диалог подтверждения, DELETE не вызывается сразу', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
vm.confirmDelete('2026-06-01');
|
||||
expect(vm.deleteDialogOpen).toBe(true);
|
||||
expect(vm.deleteTarget).toBe('2026-06-01');
|
||||
expect(adminApi.deleteScheduledPricingTier).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('openEditor сбрасывает effectiveFrom к дефолту (nextMonthStart)', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
vm.effectiveFrom = '2099-01-01';
|
||||
vm.openEditor();
|
||||
expect(vm.editorOpen).toBe(true);
|
||||
expect(vm.effectiveFrom).not.toBe('2099-01-01');
|
||||
});
|
||||
|
||||
it('performDelete вызывает deleteScheduledPricingTier для выбранной даты', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
vm.confirmDelete('2026-06-01');
|
||||
await vm.performDelete();
|
||||
expect(adminApi.deleteScheduledPricingTier).toHaveBeenCalledWith('2026-06-01');
|
||||
expect(vm.deleteDialogOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminPricingTiersView error handling (Sprint 1 G1)', () => {
|
||||
beforeEach(() => {
|
||||
// axios.isAxiosError is auto-mocked as vi.fn() by vi.mock('axios').
|
||||
// We need it to return true so extractErrorMessage() can read response.data.message.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.isAxiosError as any).mockReturnValue(true);
|
||||
});
|
||||
afterEach(() => {
|
||||
// Cleanup mockReturnValue to prevent leak into other describe blocks (review I-1).
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('submit() shows errorMessage when axios.post rejects with 422', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.get as any).mockResolvedValue({ data: { data: { active: mockTiers, scheduled: {} } } });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.post as any).mockRejectedValue({
|
||||
response: { status: 422, data: { message: 'Validation failed: tier 7 leads_in_tier must be null' } },
|
||||
});
|
||||
it('submit() shows errorMessage when createPricingTiers rejects with 422', async () => {
|
||||
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
|
||||
vi.mocked(adminApi.createPricingTiers).mockRejectedValue(
|
||||
makeAxiosError('Validation failed: tier 7 leads_in_tier must be null', 422),
|
||||
);
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// Open editor first (submit is called from dialog) so we can verify it stays open on error.
|
||||
@@ -114,10 +168,8 @@ describe('AdminPricingTiersView error handling (Sprint 1 G1)', () => {
|
||||
});
|
||||
|
||||
it('submit() shows successMessage on 200', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.get as any).mockResolvedValue({ data: { data: { active: mockTiers, scheduled: {} } } });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.post as any).mockResolvedValue({ data: { effective_from: '2026-06-01' } });
|
||||
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
|
||||
vi.mocked(adminApi.createPricingTiers).mockResolvedValue({ effective_from: '2026-06-01' });
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -131,34 +183,29 @@ describe('AdminPricingTiersView error handling (Sprint 1 G1)', () => {
|
||||
expect(vm.successToastOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('confirmDelete() shows errorMessage when axios.delete rejects', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.get as any).mockResolvedValue({ data: { data: { active: mockTiers, scheduled: {} } } });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.delete as any).mockRejectedValue({
|
||||
response: { status: 500, data: { message: 'Database connection failed' } },
|
||||
});
|
||||
window.confirm = vi.fn(() => true);
|
||||
it('performDelete() shows errorMessage when deleteScheduledPricingTier rejects', async () => {
|
||||
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
|
||||
vi.mocked(adminApi.deleteScheduledPricingTier).mockRejectedValue(
|
||||
makeAxiosError('Database connection failed', 500),
|
||||
);
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (wrapper.vm as any).confirmDelete('2026-06-01');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((wrapper.vm as any).errorMessage).toContain('Database connection failed');
|
||||
});
|
||||
|
||||
it('confirmDelete() shows successMessage on OK', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.get as any).mockResolvedValue({ data: { data: { active: mockTiers, scheduled: {} } } });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.delete as any).mockResolvedValue({ data: { ok: true } });
|
||||
window.confirm = vi.fn(() => true);
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (wrapper.vm as any).confirmDelete('2026-06-01');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
vm.confirmDelete('2026-06-01');
|
||||
await vm.performDelete();
|
||||
expect(vm.errorMessage).toContain('Database connection failed');
|
||||
});
|
||||
|
||||
it('performDelete() shows successMessage on OK', async () => {
|
||||
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
|
||||
vi.mocked(adminApi.deleteScheduledPricingTier).mockResolvedValue(undefined);
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
vm.confirmDelete('2026-06-01');
|
||||
await vm.performDelete();
|
||||
expect(vm.successMessage).toContain('Удалено');
|
||||
expect(vm.successToastOpen).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import axios from 'axios';
|
||||
|
||||
import AdminSupplierPricesView from '../../resources/js/views/admin/AdminSupplierPricesView.vue';
|
||||
|
||||
vi.mock('axios');
|
||||
/**
|
||||
* Создаёт объект, который проходит `axios.isAxiosError()` (проверяет флаг `isAxiosError: true`),
|
||||
* с нужным `response.data.message`.
|
||||
*/
|
||||
function makeAxiosError(message: string, status = 422): unknown {
|
||||
return Object.assign(new Error(message), {
|
||||
isAxiosError: true,
|
||||
response: { status, data: { message } },
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
|
||||
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
|
||||
return {
|
||||
...orig,
|
||||
getAdminSuppliers: vi.fn(),
|
||||
updateAdminSupplier: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const adminApi = await import('../../resources/js/api/admin');
|
||||
|
||||
// Auto-импорт компонентов/директив Vuetify подхватывает vite-plugin-vuetify
|
||||
// из vitest.config.ts (см. AdminPricingTiersView.spec.ts).
|
||||
@@ -19,10 +38,8 @@ const mockSuppliers = [
|
||||
|
||||
describe('AdminSupplierPricesView', () => {
|
||||
beforeEach(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.get as any).mockResolvedValue({ data: { data: mockSuppliers } });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.patch as any).mockResolvedValue({ data: { data: mockSuppliers[0] } });
|
||||
vi.mocked(adminApi.getAdminSuppliers).mockResolvedValue(mockSuppliers);
|
||||
vi.mocked(adminApi.updateAdminSupplier).mockResolvedValue(mockSuppliers[0]);
|
||||
});
|
||||
|
||||
it('renders 3 supplier rows', async () => {
|
||||
@@ -45,7 +62,7 @@ describe('AdminSupplierPricesView', () => {
|
||||
quality_score: '1.00',
|
||||
is_active: true,
|
||||
});
|
||||
expect(axios.patch).toHaveBeenCalledWith('/api/admin/suppliers/1', {
|
||||
expect(adminApi.updateAdminSupplier).toHaveBeenCalledWith(1, {
|
||||
cost_rub: '2.00',
|
||||
quality_score: '1.00',
|
||||
is_active: true,
|
||||
@@ -82,30 +99,17 @@ describe('AdminSupplierPricesView', () => {
|
||||
});
|
||||
|
||||
describe('AdminSupplierPricesView error handling (Sprint 1 G2)', () => {
|
||||
beforeEach(() => {
|
||||
// axios.isAxiosError is auto-mocked as vi.fn() by vi.mock('axios').
|
||||
// We need it to return true so extractErrorMessage() can read response.data.message.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.isAxiosError as any).mockReturnValue(true);
|
||||
});
|
||||
afterEach(() => {
|
||||
// Cleanup mockReturnValue to prevent leak into other describe blocks (review I-1).
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('save() shows per-row errorMessage when axios.patch rejects', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.get as any).mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{ id: 1, code: 'B1', name: 'Supplier 1', cost_rub: '120.00', quality_score: '8.50', is_active: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.patch as any).mockRejectedValue({
|
||||
response: { status: 422, data: { message: 'cost_rub must be non-negative' } },
|
||||
});
|
||||
it('save() shows per-row errorMessage when updateAdminSupplier rejects', async () => {
|
||||
vi.mocked(adminApi.getAdminSuppliers).mockResolvedValue([
|
||||
{ id: 1, code: 'B1', name: 'Supplier 1', cost_rub: '120.00', quality_score: '8.50', is_active: true },
|
||||
]);
|
||||
vi.mocked(adminApi.updateAdminSupplier).mockRejectedValue(
|
||||
makeAxiosError('cost_rub must be non-negative', 422),
|
||||
);
|
||||
const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const row = { id: 1, code: 'B1', name: 'Supplier 1', cost_rub: '-5.00', quality_score: '8.50', is_active: true };
|
||||
@@ -118,16 +122,12 @@ describe('AdminSupplierPricesView error handling (Sprint 1 G2)', () => {
|
||||
});
|
||||
|
||||
it('save() shows successMessage on 200', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.get as any).mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{ id: 2, code: 'B2', name: 'Supplier 2', cost_rub: '100.00', quality_score: '9.00', is_active: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.patch as any).mockResolvedValue({ data: { ok: true } });
|
||||
vi.mocked(adminApi.getAdminSuppliers).mockResolvedValue([
|
||||
{ id: 2, code: 'B2', name: 'Supplier 2', cost_rub: '100.00', quality_score: '9.00', is_active: true },
|
||||
]);
|
||||
vi.mocked(adminApi.updateAdminSupplier).mockResolvedValue(
|
||||
{ id: 2, code: 'B2', name: 'Supplier 2', cost_rub: '110.00', quality_score: '9.00', is_active: true },
|
||||
);
|
||||
const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const row = { id: 2, code: 'B2', name: 'Supplier 2', cost_rub: '110.00', quality_score: '9.00', is_active: true };
|
||||
@@ -141,11 +141,10 @@ describe('AdminSupplierPricesView error handling (Sprint 1 G2)', () => {
|
||||
expect(vm.saving[2]).toBe(false);
|
||||
});
|
||||
|
||||
it('load() sets fetchError when axios.get rejects', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.get as any).mockRejectedValue({
|
||||
response: { status: 500, data: { message: 'Database connection lost' } },
|
||||
});
|
||||
it('load() sets fetchError when getAdminSuppliers rejects', async () => {
|
||||
vi.mocked(adminApi.getAdminSuppliers).mockRejectedValue(
|
||||
makeAxiosError('Database connection lost', 500),
|
||||
);
|
||||
const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -154,18 +153,17 @@ describe('AdminSupplierPricesView error handling (Sprint 1 G2)', () => {
|
||||
});
|
||||
|
||||
it('save() clears previous error on successful retry', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.get as any).mockResolvedValue({
|
||||
data: { data: [{ id: 3, code: 'B3', name: 'Supplier 3', cost_rub: '100.00', quality_score: '8.00', is_active: true }] },
|
||||
});
|
||||
vi.mocked(adminApi.getAdminSuppliers).mockResolvedValue([
|
||||
{ id: 3, code: 'B3', name: 'Supplier 3', cost_rub: '100.00', quality_score: '8.00', is_active: true },
|
||||
]);
|
||||
// First call fails
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.patch as any).mockRejectedValueOnce({
|
||||
response: { status: 500, data: { message: 'transient' } },
|
||||
});
|
||||
vi.mocked(adminApi.updateAdminSupplier).mockRejectedValueOnce(
|
||||
makeAxiosError('transient', 500),
|
||||
);
|
||||
// Second call succeeds
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.patch as any).mockResolvedValueOnce({ data: { ok: true } });
|
||||
vi.mocked(adminApi.updateAdminSupplier).mockResolvedValueOnce(
|
||||
{ id: 3, code: 'B3', name: 'Supplier 3', cost_rub: '100.00', quality_score: '8.00', is_active: true },
|
||||
);
|
||||
|
||||
const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import BalanceCard from '../../resources/js/components/billing/BalanceCard.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
function factory() {
|
||||
return mount(BalanceCard, {
|
||||
global: { plugins: [vuetify] },
|
||||
props: {
|
||||
walletRub: 14250,
|
||||
leadsBalance: 285,
|
||||
tariffName: 'Про',
|
||||
tariffPrice: '990.00',
|
||||
tariffFeatures: ['Webhook', 'Канбан'],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('BalanceCard.vue', () => {
|
||||
it('кнопка «Пополнить» активна и эмитит topup', async () => {
|
||||
const wrapper = factory();
|
||||
const btn = wrapper.findAll('button').find((b) => b.text().includes('Пополнить'));
|
||||
expect(btn).toBeDefined();
|
||||
expect(btn!.attributes('disabled')).toBeUndefined();
|
||||
await btn!.trigger('click');
|
||||
expect(wrapper.emitted('topup')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('кнопка «Автопополнение» disabled (E2 — нет backend)', () => {
|
||||
const wrapper = factory();
|
||||
const btn = wrapper.findAll('button').find((b) => b.text().includes('Автопополнение'));
|
||||
expect(btn).toBeDefined();
|
||||
expect(btn!.attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
it('кнопка «Сменить тариф» disabled (E2 — нет backend)', () => {
|
||||
const wrapper = factory();
|
||||
const btn = wrapper.findAll('button').find((b) => b.text().includes('Сменить тариф'));
|
||||
expect(btn).toBeDefined();
|
||||
expect(btn!.attributes('disabled')).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -114,4 +114,10 @@ describe('BillingView.vue', () => {
|
||||
await btn!.trigger('click');
|
||||
expect((wrapper.vm as unknown as { topupOpen: boolean }).topupOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('не показывает pending-баннер (E4 — mock убран)', async () => {
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).not.toContain('в обработке');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,669 @@
|
||||
# Sprint 5C — Billing/Admin Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to
|
||||
> implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Закрыть 5 P2-эпиков подсистемы Billing/Admin портального аудита — E2, E4, G3, G7, G10.
|
||||
|
||||
**Architecture:** Frontend Vue 3.5 + Vuetify 3.12 + TypeScript (Composition API, `<script setup>`);
|
||||
backend Laravel 13. Изменения локализованы в 4 view/компонентах биллинга/админки + 1 контроллере +
|
||||
1 api-модуле. БД не трогаем (schema без изменений). Decide-эпики E2/E4 разрешены заказчиком
|
||||
2026-05-17: E2 → `disabled` + tooltip (паттерн 5A A1); E4 → убрать mock-баннер целиком.
|
||||
|
||||
**Tech Stack:** Vue 3.5, Vuetify 3.12, TypeScript, Pinia, vue-router 4, Pest 4, Vitest 4, Laravel 13.
|
||||
|
||||
**Порядок задач:** T1 (E2) и T2 (E4) независимы. T3→T4→T5 (G3→G7→G10) — последовательны, все три
|
||||
правят `AdminPricingTiersView.vue`; T3 — фундамент (вынос API), T4 и T5 строятся поверх.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: E2 — BalanceCard «Автопополнение» + «Сменить тариф» → `disabled` + tooltip
|
||||
|
||||
**Контекст:** `BalanceCard.vue` — три wallet-карты в `BillingView`. Кнопка «Пополнить» подвязана
|
||||
(`@click="$emit('topup')"`). Кнопки «Автопополнение» (рекуррентный биллинг) и «Сменить тариф»
|
||||
(self-service смена тарифа) — без обработчиков; backend-endpoint'ов под них нет, реализация вне
|
||||
scope P2. Решение заказчика — `disabled` + tooltip (как 5A A1 Yandex SSO). Disabled `v-btn` не
|
||||
ловит pointer-события → активатор tooltip навешивается на оборачивающий `<span>`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/resources/js/components/billing/BalanceCard.vue`
|
||||
- Create: `app/tests/Frontend/BalanceCard.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест** — `app/tests/Frontend/BalanceCard.spec.ts`
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import BalanceCard from '../../resources/js/components/billing/BalanceCard.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
function factory() {
|
||||
return mount(BalanceCard, {
|
||||
global: { plugins: [vuetify] },
|
||||
props: {
|
||||
walletRub: 14250,
|
||||
leadsBalance: 285,
|
||||
tariffName: 'Про',
|
||||
tariffPrice: '990.00',
|
||||
tariffFeatures: ['Webhook', 'Канбан'],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('BalanceCard.vue', () => {
|
||||
it('кнопка «Пополнить» активна и эмитит topup', async () => {
|
||||
const wrapper = factory();
|
||||
const btn = wrapper.findAll('button').find((b) => b.text().includes('Пополнить'));
|
||||
expect(btn).toBeDefined();
|
||||
expect(btn!.attributes('disabled')).toBeUndefined();
|
||||
await btn!.trigger('click');
|
||||
expect(wrapper.emitted('topup')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('кнопка «Автопополнение» disabled (E2 — нет backend)', () => {
|
||||
const wrapper = factory();
|
||||
const btn = wrapper.findAll('button').find((b) => b.text().includes('Автопополнение'));
|
||||
expect(btn).toBeDefined();
|
||||
expect(btn!.attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
it('кнопка «Сменить тариф» disabled (E2 — нет backend)', () => {
|
||||
const wrapper = factory();
|
||||
const btn = wrapper.findAll('button').find((b) => b.text().includes('Сменить тариф'));
|
||||
expect(btn).toBeDefined();
|
||||
expect(btn!.attributes('disabled')).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать тест — убедиться, что падает**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/BalanceCard.spec.ts`
|
||||
Expected: FAIL — «Автопополнение» и «Сменить тариф» сейчас не disabled.
|
||||
|
||||
- [ ] **Step 3: Обернуть обе кнопки в `v-tooltip` с `disabled`**
|
||||
|
||||
В `BalanceCard.vue` заменить кнопку «Автопополнение» (сейчас `<v-btn variant="outlined"
|
||||
prepend-icon="mdi-autorenew" size="small"> Автопополнение </v-btn>`) на:
|
||||
|
||||
```vue
|
||||
<v-tooltip text="Автопополнение будет доступно после подключения платёжного шлюза.">
|
||||
<template #activator="{ props: tipProps }">
|
||||
<span v-bind="tipProps" class="d-inline-flex">
|
||||
<v-btn variant="outlined" prepend-icon="mdi-autorenew" size="small" disabled>
|
||||
Автопополнение
|
||||
</v-btn>
|
||||
</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
```
|
||||
|
||||
Заменить кнопку «Сменить тариф →» (сейчас `<v-btn variant="outlined" size="small"
|
||||
class="mt-auto">Сменить тариф →</v-btn>`) на:
|
||||
|
||||
```vue
|
||||
<v-tooltip text="Самостоятельная смена тарифа появится после запуска биллинга.">
|
||||
<template #activator="{ props: tipProps }">
|
||||
<span v-bind="tipProps" class="mt-auto d-inline-flex">
|
||||
<v-btn variant="outlined" size="small" disabled>Сменить тариф →</v-btn>
|
||||
</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
```
|
||||
|
||||
Примечание: класс `mt-auto` перенесён с кнопки на `<span>`-обёртку — обёртка теперь flex-ребёнок
|
||||
карты, ей нужен `mt-auto` для прижатия книзу.
|
||||
|
||||
- [ ] **Step 4: Прогнать тест — убедиться, что проходит**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/BalanceCard.spec.ts`
|
||||
Expected: PASS (3/3).
|
||||
|
||||
- [ ] **Step 5: Lint + type-check**
|
||||
|
||||
Run: `cd app && npx vue-tsc --noEmit -p tsconfig.json && npm run lint:vue`
|
||||
Expected: 0 ошибок.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/components/billing/BalanceCard.vue app/tests/Frontend/BalanceCard.spec.ts
|
||||
git commit -m "feat(billing): E2 — disabled+tooltip на кнопках Автопополнение/Сменить тариф"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: E4 — убрать mock pending-баннер в BillingView + удалить mockBilling.ts
|
||||
|
||||
**Контекст:** `BillingView.vue` рисует `v-alert` «1 платёж в обработке» по хардкоду `MOCK_PENDING`
|
||||
из `composables/mockBilling.ts`. Платёжного шлюза нет (заблокирован Б-1), `POST /api/billing/topup`
|
||||
кредитует баланс мгновенно — состояния «платёж в обработке» в БД не существует и не появится до
|
||||
Б-1. Хардкод-баннер с фейковым «TX-89421 ЮKassa 14:21» вводит в заблуждение в проде. Решение
|
||||
заказчика — убрать баннер и файл `mockBilling.ts` целиком.
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/resources/js/views/BillingView.vue`
|
||||
- Delete: `app/resources/js/composables/mockBilling.ts`
|
||||
- Modify: `app/tests/Frontend/BillingView.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Проверить, что `mockBilling` нигде больше не используется**
|
||||
|
||||
Run: `cd app && npx --yes grep -rn "mockBilling\|MOCK_PENDING" resources tests` (либо Grep-тул)
|
||||
Expected: совпадения только в `views/BillingView.vue` и `composables/mockBilling.ts`. Если есть
|
||||
другие — остановиться и эскалировать контроллеру.
|
||||
|
||||
- [ ] **Step 2: Написать падающий тест** — добавить в `app/tests/Frontend/BillingView.spec.ts`
|
||||
внутрь `describe('BillingView.vue', ...)`:
|
||||
|
||||
```ts
|
||||
it('не показывает pending-баннер (E4 — mock убран)', async () => {
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).not.toContain('в обработке');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Прогнать тест — убедиться, что падает**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/BillingView.spec.ts`
|
||||
Expected: FAIL — баннер «1 платёж в обработке» сейчас рендерится (`MOCK_PENDING` truthy).
|
||||
|
||||
- [ ] **Step 4: Убрать баннер и импорт из `BillingView.vue`**
|
||||
|
||||
1. Удалить строку импорта: `import { MOCK_PENDING } from '../composables/mockBilling';`
|
||||
2. Удалить блок `v-alert` (`<v-alert v-if="MOCK_PENDING" ...>...</v-alert>` — целиком, ~12 строк
|
||||
внутри `<template v-else-if="wallet">` перед `<BalanceCard ...>`).
|
||||
3. В doc-комментарии (строки 8–12) убрать абзац про «Pending-баннер остаётся mock (MOCK_PENDING) —
|
||||
это отдельный эпик E4». Заменить на одну строку:
|
||||
`Sprint 5C (E4): pending-баннер убран — платёжного шлюза нет (Б-1), реального состояния «платёж в обработке» в БД не существует.`
|
||||
|
||||
- [ ] **Step 5: Удалить файл `mockBilling.ts`**
|
||||
|
||||
```bash
|
||||
git rm app/resources/js/composables/mockBilling.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Прогнать тесты — убедиться, что проходят**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/BillingView.spec.ts`
|
||||
Expected: PASS (все, включая новый тест). `formatPlain`/`featureLabel` импорты остаются — они из
|
||||
`billingFormatters`, не из `mockBilling`.
|
||||
|
||||
- [ ] **Step 7: Lint + type-check**
|
||||
|
||||
Run: `cd app && npx vue-tsc --noEmit -p tsconfig.json && npm run lint:vue`
|
||||
Expected: 0 ошибок.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/BillingView.vue app/tests/Frontend/BillingView.spec.ts
|
||||
git commit -m "feat(billing): E4 — убрать mock pending-баннер (нет платёжного шлюза до Б-1)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: G3 — AdminPricingTiersView + AdminSupplierPricesView → типизированный api/admin.ts
|
||||
|
||||
**Контекст:** Обе админ-вьюхи уже на `<script setup lang="ts">` с интерфейсами (находка аудита
|
||||
«plain JS» устарела — Sprint 1 уже типизировал). Реальный остаток G3 — вьюхи дёргают сырой
|
||||
`import axios from 'axios'` напрямую, минуя `apiClient` (без `withCredentials`/`withXSRFToken`/
|
||||
CSRF — латентный баг для прода). Остальные админ-вьюхи (`AdminBillingView`) ходят через типизо-
|
||||
ванный `api/admin.ts` + `apiClient`. Задача — вынести вызовы pricing-tiers/suppliers в `api/admin.ts`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/resources/js/api/admin.ts`
|
||||
- Modify: `app/resources/js/views/admin/AdminPricingTiersView.vue`
|
||||
- Modify: `app/resources/js/views/admin/AdminSupplierPricesView.vue`
|
||||
- Modify: `app/tests/Frontend/AdminPricingTiersView.spec.ts`
|
||||
- Modify: `app/tests/Frontend/AdminSupplierPricesView.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Добавить функции и типы в `api/admin.ts`** (в конец файла)
|
||||
|
||||
```ts
|
||||
// === SaaS-admin → Тарифная сетка (Plan 4 / Sprint 5C G3) ===
|
||||
|
||||
export interface AdminPricingTier {
|
||||
tier_no: number;
|
||||
leads_in_tier: number | null;
|
||||
price_per_lead_kopecks: number;
|
||||
effective_from: string;
|
||||
}
|
||||
|
||||
export interface PricingTiersResponse {
|
||||
active: AdminPricingTier[];
|
||||
scheduled: Record<string, AdminPricingTier[]>;
|
||||
}
|
||||
|
||||
export interface PricingTierEditorRow {
|
||||
tier_no: number;
|
||||
leads_in_tier: number | null;
|
||||
price_rub: string;
|
||||
}
|
||||
|
||||
export async function getPricingTiers(): Promise<PricingTiersResponse> {
|
||||
const { data } = await apiClient.get<{ data: PricingTiersResponse }>('/api/admin/pricing-tiers');
|
||||
return { active: data.data.active, scheduled: data.data.scheduled ?? {} };
|
||||
}
|
||||
|
||||
export async function createPricingTiers(tiers: PricingTierEditorRow[]): Promise<{ effective_from: string }> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<{ effective_from: string }>('/api/admin/pricing-tiers', { tiers });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteScheduledPricingTier(effectiveFrom: string): Promise<void> {
|
||||
await ensureCsrfCookie();
|
||||
await apiClient.delete(`/api/admin/pricing-tiers/scheduled/${effectiveFrom}`);
|
||||
}
|
||||
|
||||
// === SaaS-admin → Цены поставщиков (Plan 4 / Sprint 5C G3) ===
|
||||
|
||||
export interface AdminSupplier {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
cost_rub: string;
|
||||
quality_score: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export async function getAdminSuppliers(): Promise<AdminSupplier[]> {
|
||||
const { data } = await apiClient.get<{ data: AdminSupplier[] }>('/api/admin/suppliers');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateAdminSupplier(
|
||||
id: number,
|
||||
payload: { cost_rub: string; quality_score: string; is_active: boolean },
|
||||
): Promise<AdminSupplier> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.patch<{ data: AdminSupplier }>(`/api/admin/suppliers/${id}`, payload);
|
||||
return data.data;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Переписать `AdminPricingTiersView.vue` на api/admin**
|
||||
|
||||
1. Удалить `import axios from 'axios';`.
|
||||
2. Добавить:
|
||||
`import { getPricingTiers, createPricingTiers, deleteScheduledPricingTier, type AdminPricingTier, type PricingTierEditorRow } from '../../api/admin';`
|
||||
3. Удалить локальные интерфейсы `Tier` и `EditorRow`; заменить их использования на `AdminPricingTier`
|
||||
и `PricingTierEditorRow` соответственно (`active: ref<AdminPricingTier[]>([])`,
|
||||
`scheduled: ref<Record<string, AdminPricingTier[]>>({})`, `editor: ref<PricingTierEditorRow[]>(...)`,
|
||||
`defaultEditor: PricingTierEditorRow[]`).
|
||||
4. `load()` — заменить тело:
|
||||
```ts
|
||||
const data = await getPricingTiers();
|
||||
active.value = data.active;
|
||||
scheduled.value = data.scheduled;
|
||||
```
|
||||
5. `submit()` — заменить `await axios.post('/api/admin/pricing-tiers', { tiers: editor.value });` на
|
||||
`await createPricingTiers(editor.value);`.
|
||||
6. `confirmDelete()` — заменить `await axios.delete(\`/api/admin/pricing-tiers/scheduled/${effectiveFrom}\`);`
|
||||
на `await deleteScheduledPricingTier(effectiveFrom);`.
|
||||
7. `extractErrorMessage` остаётся (импорт из `../../api/client`).
|
||||
|
||||
- [ ] **Step 3: Переписать `AdminSupplierPricesView.vue` на api/admin**
|
||||
|
||||
1. Удалить `import axios from 'axios';`.
|
||||
2. Добавить: `import { getAdminSuppliers, updateAdminSupplier, type AdminSupplier } from '../../api/admin';`
|
||||
3. Удалить локальный интерфейс `SupplierRow`; заменить использования на `AdminSupplier`
|
||||
(`suppliers: ref<AdminSupplier[]>([])`, параметр `save(s: AdminSupplier)`).
|
||||
4. `load()` — заменить тело: `suppliers.value = await getAdminSuppliers();`.
|
||||
5. `save()` — заменить `axios.patch(...)` на:
|
||||
`await updateAdminSupplier(s.id, { cost_rub: s.cost_rub, quality_score: s.quality_score, is_active: s.is_active });`
|
||||
|
||||
- [ ] **Step 4: Переписать `AdminPricingTiersView.spec.ts` на мок api/admin**
|
||||
|
||||
Эталон паттерна — `app/tests/Frontend/AdminBillingViewActions.spec.ts`. Ключевые правки:
|
||||
1. Убрать `import axios from 'axios';` и `vi.mock('axios');`.
|
||||
2. Добавить partial-мок:
|
||||
```ts
|
||||
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
|
||||
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
|
||||
return { ...orig, getPricingTiers: vi.fn(), createPricingTiers: vi.fn(), deleteScheduledPricingTier: vi.fn() };
|
||||
});
|
||||
const adminApi = await import('../../resources/js/api/admin');
|
||||
```
|
||||
3. Добавить хелпер ошибки (копия из эталона):
|
||||
```ts
|
||||
function makeAxiosError(message: string, status = 422): unknown {
|
||||
return Object.assign(new Error(message), { isAxiosError: true, response: { status, data: { message } } });
|
||||
}
|
||||
```
|
||||
4. `mockTiers` — оставить (это `AdminPricingTier[]`).
|
||||
5. Первый `describe` `beforeEach`:
|
||||
```ts
|
||||
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
|
||||
vi.mocked(adminApi.createPricingTiers).mockResolvedValue({ effective_from: '2026-06-01' });
|
||||
vi.mocked(adminApi.deleteScheduledPricingTier).mockResolvedValue(undefined);
|
||||
```
|
||||
6. Тест `submits POST ...` → `expect(adminApi.createPricingTiers).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ tier_no: 7, leads_in_tier: null })]));`
|
||||
7. Тест `confirmDelete triggers DELETE ...` → `expect(adminApi.deleteScheduledPricingTier).toHaveBeenCalledWith('2026-06-01');` (`window.confirm = vi.fn(() => true)` — оставить, T5 уберёт).
|
||||
8. `describe` error handling — убрать `axios.isAxiosError` блок; в каждом тесте заменить
|
||||
`(axios.X as any).mockRejectedValue({response:...})` на `vi.mocked(adminApi.fn).mockRejectedValue(makeAxiosError('...', status))`,
|
||||
а `(axios.get as any).mockResolvedValue(...)` на `vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} })`.
|
||||
`afterEach(() => vi.clearAllMocks())` — оставить.
|
||||
|
||||
- [ ] **Step 5: Переписать `AdminSupplierPricesView.spec.ts` на мок api/admin**
|
||||
|
||||
Аналогично Step 4:
|
||||
1. Убрать axios; `vi.mock('../../resources/js/api/admin', ...)` с `getAdminSuppliers`/`updateAdminSupplier` как `vi.fn()`.
|
||||
2. `makeAxiosError` хелпер.
|
||||
3. `beforeEach`: `vi.mocked(adminApi.getAdminSuppliers).mockResolvedValue(mockSuppliers);`
|
||||
`vi.mocked(adminApi.updateAdminSupplier).mockResolvedValue(mockSuppliers[0]);`
|
||||
4. Тест `save() fires PATCH ...` → `expect(adminApi.updateAdminSupplier).toHaveBeenCalledWith(1, { cost_rub: '2.00', quality_score: '1.00', is_active: true });`
|
||||
5. Error-тесты → `mockRejectedValue(makeAxiosError(...))`; `load() ... rejects` → `vi.mocked(adminApi.getAdminSuppliers).mockRejectedValue(makeAxiosError('Database connection lost', 500))`.
|
||||
|
||||
- [ ] **Step 6: Прогнать оба spec-файла**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/AdminPricingTiersView.spec.ts tests/Frontend/AdminSupplierPricesView.spec.ts`
|
||||
Expected: PASS (все тесты обоих файлов).
|
||||
|
||||
- [ ] **Step 7: Lint + type-check**
|
||||
|
||||
Run: `cd app && npx vue-tsc --noEmit -p tsconfig.json && npm run lint:vue`
|
||||
Expected: 0 ошибок (в т.ч. `import axios` удалён из обеих вьюх).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/api/admin.ts app/resources/js/views/admin/AdminPricingTiersView.vue \
|
||||
app/resources/js/views/admin/AdminSupplierPricesView.vue \
|
||||
app/tests/Frontend/AdminPricingTiersView.spec.ts app/tests/Frontend/AdminSupplierPricesView.spec.ts
|
||||
git commit -m "refactor(admin): G3 — pricing-tiers/suppliers вьюхи на типизированный api/admin.ts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: G7 — AdminPricingTiers effective_from через date-picker
|
||||
|
||||
**Контекст:** Сейчас `effective_from` новой сетки жёстко = 1-е число следующего месяца (МСК):
|
||||
backend `AdminPricingTiersController@store:92` хардкодит `startOfMonth()->addMonth()`, frontend
|
||||
показывает `nextMonthStart` в кнопке и заголовке диалога. G7 — дать админу выбрать дату.
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Http/Controllers/Api/AdminPricingTiersController.php`
|
||||
- Modify: `app/resources/js/api/admin.ts`
|
||||
- Modify: `app/resources/js/views/admin/AdminPricingTiersView.vue`
|
||||
- Modify: `app/tests/Feature/Admin/AdminPricingTiersControllerTest.php`
|
||||
- Modify: `app/tests/Frontend/AdminPricingTiersView.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Написать падающий Pest-тест** — добавить в `AdminPricingTiersControllerTest.php`
|
||||
|
||||
```php
|
||||
it('store accepts a custom effective_from date', function (): void {
|
||||
$custom = \Illuminate\Support\Carbon::now('Europe/Moscow')->addMonths(3)->toDateString();
|
||||
|
||||
$response = $this->postJson('/api/admin/pricing-tiers', [
|
||||
'tiers' => validTiersPayload(),
|
||||
'effective_from' => $custom,
|
||||
]);
|
||||
|
||||
$response->assertCreated()->assertJson(['effective_from' => $custom]);
|
||||
expect(\App\Models\PricingTier::where('effective_from', $custom)->count())->toBe(7);
|
||||
});
|
||||
|
||||
it('store rejects effective_from in the past', function (): void {
|
||||
$past = \Illuminate\Support\Carbon::now('Europe/Moscow')->subDay()->toDateString();
|
||||
|
||||
$this->postJson('/api/admin/pricing-tiers', [
|
||||
'tiers' => validTiersPayload(),
|
||||
'effective_from' => $past,
|
||||
])->assertStatus(422);
|
||||
});
|
||||
```
|
||||
|
||||
Примечание: если в файле нет хелпера `validTiersPayload()` — переиспользовать массив тиров из
|
||||
существующего теста store (7 строк `tier_no`/`leads_in_tier`/`price_rub`); вынести в локальную
|
||||
функцию-хелпер в начале файла либо инлайнить массив в обоих новых тестах.
|
||||
|
||||
- [ ] **Step 2: Прогнать — убедиться, что падает**
|
||||
|
||||
Run: `cd app && php artisan test --filter=AdminPricingTiersControllerTest`
|
||||
Expected: FAIL — `effective_from` сейчас игнорируется (первый тест: дата = next-month, не custom;
|
||||
второй: 201 вместо 422).
|
||||
|
||||
- [ ] **Step 3: Backend — принять `effective_from` в `store()`**
|
||||
|
||||
В `AdminPricingTiersController@store`:
|
||||
1. Перед `$request->validate([...])` вычислить `$todayMsk = Carbon::now('Europe/Moscow')->toDateString();`
|
||||
2. В массив правил добавить:
|
||||
`'effective_from' => ['sometimes', 'date_format:Y-m-d', 'after:'.$todayMsk],`
|
||||
3. Заменить строку `$effectiveFrom = Carbon::now('Europe/Moscow')->startOfMonth()->addMonth()->toDateString();` на:
|
||||
```php
|
||||
$effectiveFrom = $request->input('effective_from')
|
||||
?? Carbon::now('Europe/Moscow')->startOfMonth()->addMonth()->toDateString();
|
||||
```
|
||||
(`$todayMsk` из шага 1 переиспользуется правилом валидации; вычислять до `validate`.)
|
||||
|
||||
- [ ] **Step 4: Прогнать Pest — убедиться, что проходит**
|
||||
|
||||
Run: `cd app && php artisan test --filter=AdminPricingTiersControllerTest`
|
||||
Expected: PASS (старые тесты store без `effective_from` → дефолт next-month; 2 новых → custom/422).
|
||||
|
||||
- [ ] **Step 5: api/admin.ts — `createPricingTiers` принимает `effectiveFrom`**
|
||||
|
||||
Изменить сигнатуру (расширение T3-функции):
|
||||
|
||||
```ts
|
||||
export async function createPricingTiers(
|
||||
tiers: PricingTierEditorRow[],
|
||||
effectiveFrom?: string,
|
||||
): Promise<{ effective_from: string }> {
|
||||
await ensureCsrfCookie();
|
||||
const payload: { tiers: PricingTierEditorRow[]; effective_from?: string } = { tiers };
|
||||
if (effectiveFrom) payload.effective_from = effectiveFrom;
|
||||
const { data } = await apiClient.post<{ effective_from: string }>('/api/admin/pricing-tiers', payload);
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Frontend — date-picker в редакторе сетки**
|
||||
|
||||
В `AdminPricingTiersView.vue`:
|
||||
1. Добавить ref после `nextMonthStart` computed:
|
||||
`const effectiveFrom = ref<string>(nextMonthStart.value);`
|
||||
2. Добавить computed для `min` (завтра):
|
||||
```ts
|
||||
const minEffectiveFrom = computed(() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
});
|
||||
```
|
||||
3. В диалоге-редакторе перед `<table class="editor-table">` добавить поле:
|
||||
```vue
|
||||
<v-text-field
|
||||
v-model="effectiveFrom"
|
||||
type="date"
|
||||
label="Дата вступления в силу"
|
||||
:min="minEffectiveFrom"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
style="max-width: 240px"
|
||||
data-testid="effective-from-input"
|
||||
/>
|
||||
```
|
||||
4. Заголовок диалога: `Новая сетка (effective_from = {{ effectiveFrom }})` (вместо `nextMonthStart`).
|
||||
Кнопку открытия редактора `Редактировать сетку (с {{ nextMonthStart }})` — оставить
|
||||
`nextMonthStart` (это дефолтная подсказка до открытия диалога).
|
||||
5. `submit()` — передать дату: `await createPricingTiers(editor.value, effectiveFrom.value);`.
|
||||
6. `successMessage` в `submit()` — использовать `effectiveFrom.value` вместо `nextMonthStart.value`.
|
||||
7. `defineExpose` — добавить `effectiveFrom`.
|
||||
|
||||
- [ ] **Step 7: Написать Vitest для date-picker** — добавить в первый `describe` `AdminPricingTiersView.spec.ts`:
|
||||
|
||||
```ts
|
||||
it('редактор содержит поле даты effective_from с дефолтом = след. месяц', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(wrapper.vm as any).editorOpen = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('[data-testid="effective-from-input"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('submit передаёт выбранную effective_from в createPricingTiers', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(wrapper.vm as any).effectiveFrom = '2026-09-01';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (wrapper.vm as any).submit();
|
||||
expect(adminApi.createPricingTiers).toHaveBeenCalledWith(expect.any(Array), '2026-09-01');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Прогнать FE-тест**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/AdminPricingTiersView.spec.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Lint + type-check + Pint**
|
||||
|
||||
Run: `cd app && npx vue-tsc --noEmit -p tsconfig.json && npm run lint:vue && composer pint -- --dirty`
|
||||
Expected: 0 ошибок.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Http/Controllers/Api/AdminPricingTiersController.php app/resources/js/api/admin.ts \
|
||||
app/resources/js/views/admin/AdminPricingTiersView.vue \
|
||||
app/tests/Feature/Admin/AdminPricingTiersControllerTest.php app/tests/Frontend/AdminPricingTiersView.spec.ts
|
||||
git commit -m "feat(admin): G7 — выбор effective_from тарифной сетки через date-picker"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: G10 — AdminPricingTiers `window.confirm` → `v-dialog`
|
||||
|
||||
**Контекст:** `AdminPricingTiersView@confirmDelete` использует браузерный `window.confirm()` для
|
||||
подтверждения удаления запланированного набора тиров. Браузерный `confirm` блокирует UI и не
|
||||
доступен ассистивным технологиям — заменить на `v-dialog`. (Аудит назвал эпик «AdminBilling
|
||||
confirm()», но в `AdminBillingView` `confirm()` уже нет — Sprint 3D G4 заменил row-actions на
|
||||
`v-dialog`'и; фактический оставшийся браузерный confirm в админ-биллинге — здесь, в pricing-tiers.)
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/resources/js/views/admin/AdminPricingTiersView.vue`
|
||||
- Modify: `app/tests/Frontend/AdminPricingTiersView.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Написать падающие тесты** — заменить в первом `describe` тест
|
||||
`confirmDelete triggers DELETE to /scheduled/{date}` на два теста:
|
||||
|
||||
```ts
|
||||
it('confirmDelete открывает диалог подтверждения, DELETE не вызывается сразу', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
vm.confirmDelete('2026-06-01');
|
||||
expect(vm.deleteDialogOpen).toBe(true);
|
||||
expect(vm.deleteTarget).toBe('2026-06-01');
|
||||
expect(adminApi.deleteScheduledPricingTier).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('performDelete вызывает deleteScheduledPricingTier для выбранной даты', async () => {
|
||||
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
vm.confirmDelete('2026-06-01');
|
||||
await vm.performDelete();
|
||||
expect(adminApi.deleteScheduledPricingTier).toHaveBeenCalledWith('2026-06-01');
|
||||
expect(vm.deleteDialogOpen).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
В error-handling `describe` тест `confirmDelete() shows errorMessage when ... rejects` —
|
||||
переименовать вызов: после `vm.confirmDelete('2026-06-01')` вызывать `await vm.performDelete()`
|
||||
(ошибку проверять после `performDelete`). Убрать `window.confirm = vi.fn(() => true)` из всех
|
||||
тестов этого файла (больше не нужен).
|
||||
|
||||
- [ ] **Step 2: Прогнать — убедиться, что падает**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/AdminPricingTiersView.spec.ts`
|
||||
Expected: FAIL — `deleteDialogOpen`/`deleteTarget`/`performDelete` ещё не существуют.
|
||||
|
||||
- [ ] **Step 3: Заменить `window.confirm` на `v-dialog`-flow**
|
||||
|
||||
В `AdminPricingTiersView.vue`:
|
||||
1. Добавить state: `const deleteDialogOpen = ref(false);` и `const deleteTarget = ref<string | null>(null);`
|
||||
2. Заменить функцию `confirmDelete` — теперь только открывает диалог:
|
||||
```ts
|
||||
function confirmDelete(effectiveFrom: string): void {
|
||||
deleteTarget.value = effectiveFrom;
|
||||
deleteDialogOpen.value = true;
|
||||
}
|
||||
```
|
||||
3. Добавить `performDelete` — фактическое удаление (тело — бывший `confirmDelete` без `window.confirm`):
|
||||
```ts
|
||||
async function performDelete(): Promise<void> {
|
||||
const effectiveFrom = deleteTarget.value;
|
||||
if (effectiveFrom === null) return;
|
||||
deleteDialogOpen.value = false;
|
||||
errorMessage.value = null;
|
||||
successMessage.value = null;
|
||||
try {
|
||||
await deleteScheduledPricingTier(effectiveFrom);
|
||||
successMessage.value = `Удалено: запланированный набор с ${effectiveFrom}.`;
|
||||
successToastOpen.value = true;
|
||||
await load();
|
||||
} catch (err) {
|
||||
errorMessage.value = extractErrorMessage(err, 'Не удалось удалить запланированный набор.');
|
||||
} finally {
|
||||
deleteTarget.value = null;
|
||||
}
|
||||
}
|
||||
```
|
||||
4. В `<template>` после диалога-редактора добавить confirm-диалог:
|
||||
```vue
|
||||
<v-dialog v-model="deleteDialogOpen" max-width="440">
|
||||
<v-card>
|
||||
<v-card-title>Удалить запланированный набор?</v-card-title>
|
||||
<v-card-text>
|
||||
Запланированная сетка с <strong>{{ deleteTarget }}</strong> будет удалена.
|
||||
Действие необратимо.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="deleteDialogOpen = false">Отмена</v-btn>
|
||||
<v-btn color="error" data-testid="confirm-delete-btn" @click="performDelete">Удалить</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
```
|
||||
5. `defineExpose` — добавить `deleteDialogOpen`, `deleteTarget`, `performDelete`.
|
||||
|
||||
- [ ] **Step 4: Прогнать FE-тест — убедиться, что проходит**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/AdminPricingTiersView.spec.ts`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Lint + type-check**
|
||||
|
||||
Run: `cd app && npx vue-tsc --noEmit -p tsconfig.json && npm run lint:vue`
|
||||
Expected: 0 ошибок (в т.ч. `window.confirm` удалён из вьюхи).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/admin/AdminPricingTiersView.vue app/tests/Frontend/AdminPricingTiersView.spec.ts
|
||||
git commit -m "feat(admin): G10 — браузерный confirm() удаления сетки → v-dialog"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Финал
|
||||
|
||||
После всех 5 задач — финальный holistic review всей реализации, затем полная регрессия
|
||||
(`/regression full`: Pest --parallel, Vitest, Vite build, vue-tsc, ESLint, Pint, Larastan,
|
||||
markdownlint, cspell, lychee, gitleaks) и `superpowers:finishing-a-development-branch`.
|
||||
|
||||
**Ожидаемые изменения относительно базы `345d14d`:** 5 feat/refactor-коммитов + этот plan-коммит.
|
||||
Файлы: `BalanceCard.vue`, `BillingView.vue`, `mockBilling.ts` (удалён), `api/admin.ts`,
|
||||
`AdminPricingTiersView.vue`, `AdminSupplierPricesView.vue`, `AdminPricingTiersController.php`,
|
||||
+ 5 spec-файлов (1 новый `BalanceCard.spec.ts`). БД/schema — без изменений.
|
||||
Reference in New Issue
Block a user