// retail-sales-parallel.js — нагрузка на проведение чеков. // // Сценарий: один tenant создан вне теста (или одним setup), все VU // проводят чеки параллельно. Цель — увидеть p95/p99 операции // «создать draft → /post → дождаться NoContent» под нагрузкой. // // По умолчанию: 1000 итераций за 5 минут (~3.3 RPS). Подкручивается // через DURATION_S и TARGET_ITERS env'ами. // // ВАЖНО: // - Перед запуском нужен tenant с продуктом и приёмкой. setup() // делает всё сам: signup → seed product → post supply на 10 000 шт. // Один setup на запуск — потом VU работают параллельно с тем же // токеном. Если итерация не находит остатка (например, тестовый // tenant закончился) — фейлим check. // - Запуск против stage заметно дольше из-за сетевого RTT (~30-100мс // на запрос). Локально на dev-vm обычно в 3-5 раз быстрее. import http from 'k6/http'; import { check } from 'k6'; import { Trend, Rate, Counter } from 'k6/metrics'; const BASE_URL = __ENV.BASE_URL || 'http://localhost:5081'; const DURATION_S = Number(__ENV.DURATION_S || 300); // 5 минут const TARGET_ITERS = Number(__ENV.TARGET_ITERS || 1000); const draftTrend = new Trend('sale_draft_ms', true); const postTrend = new Trend('sale_post_ms', true); const totalTrend = new Trend('sale_total_ms', true); const post4xx = new Rate('post_4xx'); const completed = new Counter('sales_completed'); const VUS = Number(__ENV.VUS || 5); export const options = { scenarios: { retail_sales: { executor: 'shared-iterations', iterations: TARGET_ITERS, vus: VUS, // дефолт 5 параллельных кассиров maxDuration: `${DURATION_S}s`, }, }, thresholds: { // p95 «draft + post» на stage обычно 200-700ms. sale_total_ms: ['p(95)<3000', 'p(99)<6000'], // ⚠️ При VU > 5 на одном tenant'е будут конфликты на уникальный // индекс RetailSale.Number (генератор номера расы) — это // отдельная задача для оптимизации (см. performance-baseline.md). post_4xx: ['rate<0.10'], }, }; // ── setup: один раз для всех VU ──────────────────────────────────────── export function setup() { const slug = `load-sales-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const email = `${slug}@example.kz`; const password = 'Passw0rd!'; // 1. Signup let r = http.post(`${BASE_URL}/api/auth/signup`, JSON.stringify({ email, password, organizationName: `LoadOrg-${slug}`, phone: '+77001234567', plan: null, }), { headers: { 'Content-Type': 'application/json' } }); if (r.status >= 300) throw new Error(`signup failed: ${r.status} ${r.body}`); // 2. Token r = http.post(`${BASE_URL}/connect/token`, `grant_type=password&username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}&client_id=food-market-web&scope=openid profile email roles api offline_access`, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); if (r.status !== 200) throw new Error(`token failed: ${r.status} ${r.body}`); const token = r.json('access_token'); const auth = { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` } }; // 3. Достаём refs (unit/group/store/currency/priceType) const unitsRes = http.get(`${BASE_URL}/api/catalog/units-of-measure?pageSize=200`, auth); const unit = unitsRes.json('items').find(u => u.code === '796'); const groupsRes = http.get(`${BASE_URL}/api/catalog/product-groups`, auth); const group = groupsRes.json('items')[0]; const ptsRes = http.get(`${BASE_URL}/api/catalog/price-types`, auth); const pt = ptsRes.json('items').find(p => p.isRetail); const cursRes = http.get(`${BASE_URL}/api/catalog/currencies`, auth); const cur = cursRes.json('items').find(c => c.code === 'KZT'); const storesRes = http.get(`${BASE_URL}/api/catalog/stores`, auth); const store = storesRes.json('items').find(s => s.isMain); const rpsRes = http.get(`${BASE_URL}/api/catalog/retail-points`, auth); const retailPoint = rpsRes.json('items')[0]; // 4. Создаём продукт const prodRes = http.post(`${BASE_URL}/api/catalog/products`, JSON.stringify({ name: 'Load test product', article: `LT-${slug}`, unitOfMeasureId: unit.id, vat: 12, vatEnabled: true, productGroupId: group.id, packaging: 1, prices: [{ priceTypeId: pt.id, amount: 100, currencyId: cur.id }], barcodes: [{ code: `9990000${Math.floor(Math.random()*1e6).toString().padStart(7,'0')}`, type: 1, isPrimary: true }], }), auth); if (prodRes.status >= 300) throw new Error(`product failed: ${prodRes.status} ${prodRes.body}`); const productId = prodRes.json('id'); // 5. Поставщик + приёмка на 10 000 шт. const supRes = http.post(`${BASE_URL}/api/catalog/counterparties`, JSON.stringify({ name: 'LoadSupplier', type: 2 }), auth); const supplierId = supRes.json('id'); const supplyRes = http.post(`${BASE_URL}/api/purchases/supplies`, JSON.stringify({ date: new Date().toISOString(), supplierId, storeId: store.id, currencyId: cur.id, lines: [{ productId, quantity: 10000, unitPrice: 50 }], }), auth); const supplyId = supplyRes.json('id'); const postSupplyRes = http.post(`${BASE_URL}/api/purchases/supplies/${supplyId}/post`, null, auth); if (postSupplyRes.status >= 300) throw new Error(`supply post failed: ${postSupplyRes.status}`); return { token, productId, storeId: store.id, retailPointId: retailPoint.id, currencyId: cur.id, }; } // ── default fn: каждая итерация = один проведённый чек ───────────────── export default function (ctx) { const auth = { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ctx.token}`, }, }; const t0 = Date.now(); // 1. Создать draft (POST /api/sales/retail) const draftPayload = JSON.stringify({ date: new Date().toISOString(), storeId: ctx.storeId, retailPointId: ctx.retailPointId, customerId: null, currencyId: ctx.currencyId, payment: 0, isReturn: false, lines: [{ productId: ctx.productId, quantity: 1, unitPrice: 100, discount: 0, vatPercent: 12, }], subtotal: 100, discountTotal: 0, total: 100, paidCash: 100, paidCard: 0, notes: 'k6-load', }); const tDraft0 = Date.now(); const draftRes = http.post(`${BASE_URL}/api/sales/retail`, draftPayload, auth); draftTrend.add(Date.now() - tDraft0); const draftOk = check(draftRes, { 'draft 2xx': (r) => r.status >= 200 && r.status < 300 }); if (!draftOk) { post4xx.add(1); return; } const saleId = draftRes.json('id'); // 2. Провести (POST /api/sales/retail/{id}/post) const tPost0 = Date.now(); const postRes = http.post(`${BASE_URL}/api/sales/retail/${saleId}/post`, null, auth); postTrend.add(Date.now() - tPost0); post4xx.add(postRes.status >= 400); check(postRes, { 'post 204': (r) => r.status === 204 }); totalTrend.add(Date.now() - t0); if (postRes.status === 204) completed.add(1); }