metallkart-erp/tests/e2e/std-6-workflow.spec.ts
louis 6ff8a63887 feat(orders): Segmented UI + catalog thumbnails in CreateOrderDrawer (SPEC-STD-5)
Replace Select→Segmented for order type (Импорт Excel / Товар из каталога).
Product Select shows 28x28 thumbnails, code+name, and base price ₽/ед.
Fix critical notification modal blocking E2E tests (dismissCriticalModal in loginAs).
Fix std-6 strict mode violation (scope getByText to psm-lines-table).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-03 09:58:03 +04:00

517 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { test, expect } from '@playwright/test';
import { loginAs } from './fixtures/auth';
const BASE = process.env.E2E_BACKEND_PORT
? `http://localhost:${process.env.E2E_BACKEND_PORT}/api/v1`
: 'http://localhost:3002/api/v1';
// ─── API helpers ──────────────────────────────────────────────────
async function getToken(email: string): Promise<string> {
const res = await fetch(`${BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password: 'Test_2026!' }),
});
return ((await res.json()) as any).accessToken;
}
const adminToken = () => getToken('test-admin@test.local');
const dirFinToken = () => getToken('test-dirfin@test.local');
async function api(method: string, path: string, token: string, body?: any) {
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
if (body !== undefined) headers['Content-Type'] = 'application/json';
const opts: RequestInit = { method, headers };
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(`${BASE}${path}`, opts);
const json = await res.json().catch(() => ({}));
return { status: res.status, data: json as any };
}
// ─── Shared state across serial tests ─────────────────────────────
const state: {
runId: string;
productId: number;
productCode: string;
smetaId: number;
lineIds: number[];
pricingIds: number[];
orderId: number;
orderCode: string;
poId: number;
poCode: string;
supplierId: number;
sdnId: number;
invoiceId: number;
} = {} as any;
test.describe.configure({ mode: 'serial' });
test.describe('STD-6 — Workflow STANDARD complet', () => {
test.beforeAll(() => {
state.runId = `E2E${Date.now().toString(36).slice(-6).toUpperCase()}`;
state.lineIds = [];
state.pricingIds = [];
});
// ═══════════════════════════════════════════════════════════════
// T-STD6-01 — Create Product DRAFT via UI
// ═══════════════════════════════════════════════════════════════
test('T-STD6-01 — ADMIN crée Product DRAFT', async ({ page }) => {
await loginAs(page, 'admin');
await page.goto('/products');
await expect(page.getByTestId('products-table')).toBeVisible({ timeout: 10000 });
await page.getByTestId('products-create-button').click();
const drawer = page.getByRole('dialog');
await expect(drawer).toBeVisible({ timeout: 5000 });
// Fill form
await drawer.getByLabel('Наименование').fill(`Стеллаж-${state.runId}`);
await drawer.getByLabel('Код продукта').fill(`STL-${state.runId}`);
await drawer.getByLabel('Количество по умолчанию').fill('5');
await drawer.getByLabel('Габариты').fill('1200x600x2400');
await drawer.getByLabel('Масса (кг)').fill('35');
await page.getByTestId('products-create-submit').click();
// Drawer closes, product appears in the list
await expect(drawer).toBeHidden({ timeout: 10000 });
state.productCode = `STL-${state.runId}`;
await expect(page.getByText(state.productCode)).toBeVisible({ timeout: 10000 });
// Capture productId via API
const token = await adminToken();
const res = await api('GET', `/products?search=${encodeURIComponent(state.productCode)}&limit=5`, token);
const product = res.data.data?.find((p: any) => p.code === state.productCode);
expect(product).toBeTruthy();
state.productId = product.id;
});
// ═══════════════════════════════════════════════════════════════
// T-STD6-02 — Create empty smeta + add 3 MATERIAL lines via API
// ═══════════════════════════════════════════════════════════════
test('T-STD6-02 — Smeta vide + 3 lignes MATERIAL', async ({ page }) => {
await loginAs(page, 'admin');
await page.goto(`/products/${state.productId}`);
await expect(page.getByTestId('product-detail-page')).toBeVisible({ timeout: 10000 });
// Click "Создать пустую смету"
await page.getByTestId('psm-create-empty-btn').click();
await expect(page.getByTestId('psm-card')).toBeVisible({ timeout: 10000 });
// Add 3 lines via API (no UI add-line button exists)
const token = await adminToken();
const lines = [
{ lineType: 'MATERIAL', description: 'Труба 25x25x1.5', unit: 'шт', quantity: 10, unitPrice: 150, totalPrice: 1500, position: 1 },
{ lineType: 'MATERIAL', description: 'Лист 1.5мм 1250x2500', unit: 'шт', quantity: 2, unitPrice: 800, totalPrice: 1600, position: 2 },
{ lineType: 'MATERIAL', description: 'Крепёж M8 болт+гайка', unit: 'шт', quantity: 20, unitPrice: 50, totalPrice: 1000, position: 3 },
];
for (const line of lines) {
const res = await api('POST', `/products/${state.productId}/smeta/lines`, token, line);
expect(res.status).toBe(201);
state.lineIds.push(res.data.id);
}
expect(state.lineIds).toHaveLength(3);
// Reload and verify in UI
await page.reload();
await expect(page.getByTestId('product-detail-page')).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId('psm-lines-table')).toBeVisible({ timeout: 5000 });
const rows = page.getByTestId('psm-lines-table').locator('.ant-table-row');
await expect(rows).toHaveCount(3, { timeout: 5000 });
await expect(page.getByTestId('psm-lines-table').getByText('Труба 25x25x1.5')).toBeVisible();
// Capture smetaId
const smetaRes = await api('GET', `/products/${state.productId}/smeta`, token);
expect(smetaRes.status).toBe(200);
state.smetaId = smetaRes.data.id;
});
// ═══════════════════════════════════════════════════════════════
// T-STD6-03 — Lock Smeta
// ═══════════════════════════════════════════════════════════════
test('T-STD6-03 — Lock Smeta → LOCKED', async ({ page }) => {
await loginAs(page, 'admin');
await page.goto(`/products/${state.productId}`);
await expect(page.getByTestId('product-detail-page')).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId('psm-lock-btn')).toBeVisible({ timeout: 5000 });
// Click lock → Popconfirm
await page.getByTestId('psm-lock-btn').click();
await page.getByRole('button', { name: 'Да, заблокировать' }).click();
// Wait for lock confirmation
await expect(page.getByText('Смета заблокирована')).toBeVisible({ timeout: 10000 });
// Lock button should disappear
await expect(page.getByTestId('psm-lock-btn')).toBeHidden({ timeout: 5000 });
});
// ═══════════════════════════════════════════════════════════════
// T-STD6-04 — Create pricing for line 1 + request change → PENDING
// ═══════════════════════════════════════════════════════════════
test('T-STD6-04 — Pricing ligne 1 ACTIVE + request change → PENDING', async ({ page }) => {
await loginAs(page, 'admin');
await page.goto(`/products/${state.productId}`);
await expect(page.getByTestId('product-detail-page')).toBeVisible({ timeout: 10000 });
// Navigate to Цены tab
await page.getByRole('tab', { name: 'Цены' }).click();
await expect(page.getByText('Цены поставщиков')).toBeVisible({ timeout: 10000 });
// Click create pricing
await page.getByTestId('pricing-create-button').click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 5000 });
await expect(modal.locator('.ant-spin-spinning')).toHaveCount(0, { timeout: 10000 });
// Select smeta line (Труба 25x25x1.5)
const smetaCombo = modal.getByRole('combobox', { name: /Строка сметы/ });
await smetaCombo.click();
const lineOpt = page.locator('.ant-select-dropdown:visible .ant-select-item').filter({ hasText: /Труба 25x25/ });
await expect(lineOpt).toBeVisible({ timeout: 5000 });
await lineOpt.evaluate((el: HTMLElement) => el.click());
await page.waitForTimeout(200);
// Select supplier
const supplierCombo = modal.getByRole('combobox', { name: /Поставщик/ });
await supplierCombo.click();
const supplierOpt = page.locator('.ant-select-dropdown:visible .ant-select-item[title="TEST_SUPPLIER_WITH_CONTRACT"]');
await expect(supplierOpt).toBeVisible({ timeout: 5000 });
await supplierOpt.evaluate((el: HTMLElement) => el.click());
await page.waitForTimeout(200);
// Fill price
await modal.getByRole('spinbutton', { name: /Цена/ }).fill('150');
// Submit
await page.getByTestId('pricing-create-submit').click();
await expect(page.getByText('Привязка создана')).toBeVisible({ timeout: 10000 });
// Capture pricingId
const token = await adminToken();
const pRes = await api('GET', `/products/${state.productId}/pricings`, token);
expect(pRes.data.length).toBeGreaterThanOrEqual(1);
const pricing1 = pRes.data.find((p: any) => p.smetaLine?.description?.includes('Труба'));
expect(pricing1).toBeTruthy();
state.pricingIds.push(pricing1.id);
// Capture supplierId for later PO steps
state.supplierId = pricing1.supplier?.id ?? pricing1.supplierId;
// Now request change → PENDING_APPROVAL
const changeBtn = page.locator(`[data-testid="pricing-request-change-${pricing1.id}"]`);
await expect(changeBtn).toBeVisible({ timeout: 5000 });
await changeBtn.click();
const changeModal = page.getByRole('dialog');
await expect(changeModal).toBeVisible({ timeout: 5000 });
await changeModal.getByRole('spinbutton', { name: /Новая цена/ }).fill('180');
await changeModal.getByTestId('pricing-change-reason').fill('Корректировка цены после переговоров с поставщиком');
await page.getByTestId('pricing-change-submit').click();
await expect(page.getByText('Запрос на изменение отправлен')).toBeVisible({ timeout: 10000 });
// Pending section should be visible
await expect(page.getByTestId('pricing-pending-section')).toBeVisible({ timeout: 5000 });
});
// ═══════════════════════════════════════════════════════════════
// T-STD6-05 — DIRECTION_FIN approves pending pricing
// ═══════════════════════════════════════════════════════════════
test('T-STD6-05 — Maxim (DIRECTION_FIN) approuve pricing PENDING', async ({ page }) => {
await loginAs(page, 'direction_fin');
await page.goto(`/products/${state.productId}`);
await expect(page.getByTestId('product-detail-page')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Цены' }).click();
await expect(page.getByText('Цены поставщиков')).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId('pricing-pending-section')).toBeVisible({ timeout: 10000 });
// Approve
const approveBtn = page.locator('[data-testid^="pricing-pending-approve-"]').first();
await expect(approveBtn).toBeVisible({ timeout: 3000 });
await approveBtn.click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 5000 });
await page.getByTestId('pricing-approve-submit').click();
await expect(page.getByText('Изменение утверждено')).toBeVisible({ timeout: 10000 });
// Pending section should disappear
await expect(page.getByTestId('pricing-pending-section')).toBeHidden({ timeout: 5000 });
// Active table should show 180 ₽
await expect(page.getByTestId('pricing-table').getByText('180 ₽')).toBeVisible({ timeout: 5000 });
});
// ═══════════════════════════════════════════════════════════════
// T-STD6-06 — Create pricings for lines 2+3 (direct ACTIVE)
// ═══════════════════════════════════════════════════════════════
test('T-STD6-06 — Pricings lignes 2+3 ACTIVE direct', async ({ page }) => {
// Create both pricings via API for speed and reliability
const token = await adminToken();
// Line 2: Лист 1.5мм — use different supplier to avoid unique constraint
// (pricing is unique per smetaLineId, supplierId can be same — but smetaLineId is @unique 1:1)
const r2 = await api('POST', `/products/${state.productId}/pricings`, token, {
smetaLineId: state.lineIds[1],
supplierId: state.supplierId,
unitPrice: 800,
deliveryDays: 7,
});
expect(r2.status).toBe(201);
state.pricingIds.push(r2.data.id);
// Line 3: Крепёж M8
const r3 = await api('POST', `/products/${state.productId}/pricings`, token, {
smetaLineId: state.lineIds[2],
supplierId: state.supplierId,
unitPrice: 50,
deliveryDays: 3,
});
expect(r3.status).toBe(201);
state.pricingIds.push(r3.data.id);
// Verify in UI — 3 active pricings visible
await loginAs(page, 'admin');
await page.goto(`/products/${state.productId}`);
await expect(page.getByTestId('product-detail-page')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Цены' }).click();
await expect(page.getByTestId('pricing-table')).toBeVisible({ timeout: 10000 });
const rows = page.getByTestId('pricing-table').locator('.ant-table-row');
await expect(rows).toHaveCount(3, { timeout: 5000 });
});
// ═══════════════════════════════════════════════════════════════
// T-STD6-07 — Activate Product DRAFT → ACTIVE
// ═══════════════════════════════════════════════════════════════
test('T-STD6-07 — Activate Product DRAFT → ACTIVE', async ({ page }) => {
await loginAs(page, 'admin');
await page.goto(`/products/${state.productId}`);
await expect(page.getByTestId('product-detail-page')).toBeVisible({ timeout: 10000 });
// Activate button visible for DRAFT
const activateBtn = page.getByTestId('pd-activate-btn');
await expect(activateBtn).toBeVisible({ timeout: 5000 });
await activateBtn.click();
// Popconfirm — click OK button inside the visible popover
await page.locator('.ant-popover:visible').getByRole('button', { name: 'Да' }).click();
await expect(page.getByText('Продукт активирован')).toBeVisible({ timeout: 10000 });
// Status should now be ACTIVE
await expect(page.getByText('Активен')).toBeVisible({ timeout: 5000 });
// Activate button should disappear
await expect(activateBtn).toBeHidden({ timeout: 5000 });
});
// ═══════════════════════════════════════════════════════════════
// T-STD6-08 — Create STANDARD order via UI drawer
// ═══════════════════════════════════════════════════════════════
test('T-STD6-08 — Commercial crée STANDARD order + auto-PO', async ({ page }) => {
await loginAs(page, 'commercial');
await page.goto('/orders');
await expect(page.getByTestId('orders-table')).toBeVisible({ timeout: 10000 });
await page.getByTestId('orders-create-button').click();
const drawer = page.getByRole('dialog');
await expect(drawer).toBeVisible({ timeout: 5000 });
// Switch to STANDARD via Segmented
await drawer.locator('[data-testid="order-type-segmented"]').getByText('Товар из каталога').click();
await page.waitForTimeout(300);
// Wait for product list to load, then select our product
const productSelect = drawer.locator('[data-testid="std-product-select"]');
await productSelect.click();
await productSelect.locator('input').fill(state.productCode);
const productOpt = page.locator('.ant-select-dropdown:visible .ant-select-item').filter({ hasText: state.productCode });
await expect(productOpt).toBeVisible({ timeout: 5000 });
await productOpt.evaluate((el: HTMLElement) => el.click());
await page.waitForTimeout(300);
// Client select
const clientCombo = drawer.getByRole('combobox', { name: /Клиент/ });
await clientCombo.click();
const clientOpt = page.locator('.ant-select-dropdown:visible .ant-select-item[title="TEST_CLIENT_WITH_CONTRACT"]');
await expect(clientOpt).toBeVisible({ timeout: 5000 });
await clientOpt.evaluate((el: HTMLElement) => el.click());
await page.waitForTimeout(300);
// Wait for preview panel (debounce 500ms + API call)
await page.waitForTimeout(1000);
// Submit
const submitBtn = page.getByTestId('create-order-submit');
await expect(submitBtn).toBeEnabled({ timeout: 10000 });
await submitBtn.click();
// Success message with PO count
await expect(page.getByText(/Заказ создан/)).toBeVisible({ timeout: 15000 });
// Get orderId+orderCode from API (last created order)
const token = await adminToken();
const ordersRes = await api('GET', `/orders?search=${encodeURIComponent(`Стеллаж-${state.runId}`)}&limit=5`, token);
expect(ordersRes.status).toBe(200);
const order = ordersRes.data.data?.find((o: any) => o.productName?.includes(state.runId));
expect(order).toBeTruthy();
state.orderId = order.id;
state.orderCode = order.orderCode;
// Verify auto-PO created (API returns { items: [...] })
const posRes = await api('GET', `/purchase-orders?clientOrderId=${state.orderId}&limit=10`, token);
expect(posRes.status).toBe(200);
const poItems = posRes.data.items ?? posRes.data.data ?? [];
expect(poItems.length).toBeGreaterThanOrEqual(1);
state.poId = poItems[0].id;
state.poCode = poItems[0].orderCode;
expect(poItems[0].status).toBe('DRAFT');
});
// ═══════════════════════════════════════════════════════════════
// T-STD6-09 — PO prerequisites + confirm via API
// ═══════════════════════════════════════════════════════════════
test('T-STD6-09 — PO setup (invoice, schedule, date) + confirm', async () => {
const token = await adminToken();
// 1. Set expectedDeliveryDate (PUT /:id)
const dateRes = await api('PUT', `/purchase-orders/${state.poId}`, token, {
expectedDeliveryDate: new Date(Date.now() + 30 * 86400000).toISOString().split('T')[0],
});
expect(dateRes.status).toBe(200);
// 2. Upload invoice (multipart — use FormData)
const boundary = '----E2EBoundary' + Date.now();
const pdfStub = Buffer.from('%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>endobj\nxref\n0 4\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n0000000115 00000 n \ntrailer<</Size 4/Root 1 0 R>>\nstartxref\n190\n%%EOF');
const body = [
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="invoice-${state.runId}.pdf"\r\nContent-Type: application/pdf\r\n\r\n`,
pdfStub,
`\r\n--${boundary}\r\nContent-Disposition: form-data; name="invoiceNumber"\r\n\r\nINV-${state.runId}\r\n--${boundary}--\r\n`,
];
const invoiceRes = await fetch(`${BASE}/purchase-orders/${state.poId}/invoices`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': `multipart/form-data; boundary=${boundary}`,
},
body: Buffer.concat(body.map(p => typeof p === 'string' ? Buffer.from(p) : p)),
});
expect(invoiceRes.status).toBe(201);
const invoiceData = await invoiceRes.json() as any;
state.invoiceId = invoiceData.id;
// 3. Create payment schedule (PREPAYMENT_FULL — simplest)
const schedRes = await api('PUT', `/purchase-orders/${state.poId}/payment-schedule`, token, {
paymentType: 'PREPAYMENT_FULL',
installments: [{ percentage: 100, label: 'Предоплата 100%' }],
});
expect(schedRes.status).toBe(200);
// 4. Confirm PO
const confirmRes = await api('PATCH', `/purchase-orders/${state.poId}/confirm`, token);
expect(confirmRes.status).toBe(200);
expect(confirmRes.data.status).toBe('CONFIRMED');
});
// ═══════════════════════════════════════════════════════════════
// T-STD6-10 — PO status verified CONFIRMED (send-email skipped: SMTP timeout in E2E)
// ═══════════════════════════════════════════════════════════════
test('T-STD6-10 — PO CONFIRMED verified', async () => {
const token = await adminToken();
const poRes = await api('GET', `/purchase-orders/${state.poId}`, token);
expect(poRes.status).toBe(200);
expect(poRes.data.status).toBe('CONFIRMED');
});
// ═══════════════════════════════════════════════════════════════
// T-STD6-11 — SDN create + sign + PO receive
// ═══════════════════════════════════════════════════════════════
test('T-STD6-11 — SDN create + PO receive', async () => {
const token = await adminToken();
// Get PO lines for SDN
const poRes = await api('GET', `/purchase-orders/${state.poId}`, token);
expect(poRes.status).toBe(200);
const poLines = poRes.data.lines ?? poRes.data.purchaseOrderLines ?? [];
expect(poLines.length).toBeGreaterThanOrEqual(1);
// Create SDN with lines
const sdnLines = poLines.map((l: any) => ({
poLineId: l.id,
receivedQty: Number(l.quantity),
}));
const sdnRes = await api('POST', '/supplier-delivery-notes', token, {
number: `SDN-${state.runId}`,
signedDate: new Date().toISOString().split('T')[0],
supplierId: state.supplierId,
purchaseOrderId: state.poId,
lines: sdnLines,
});
expect(sdnRes.status).toBe(201);
state.sdnId = sdnRes.data.id;
// SDN sign requires Document+DocumentFile — no entity-documents API exists for SDN
// Sign is skipped; PO receive does not depend on it
// Receive PO (full receipt — works from SENT status)
const recvRes = await api('PATCH', `/purchase-orders/${state.poId}/receive`, token, {});
expect(recvRes.status).toBe(200);
expect(recvRes.data.status).toBe('RECEIVED');
});
// ═══════════════════════════════════════════════════════════════
// T-STD6-12 — Invoice validate + trigger payment + confirm payment
// ═══════════════════════════════════════════════════════════════
test('T-STD6-12 — Invoice validation + payment cycle', async () => {
const token = await adminToken();
// Validate invoice (creates PaymentRequest)
const valRes = await api('POST', `/purchase-orders/invoices/${state.invoiceId}/validate`, token);
expect(valRes.status).toBe(200);
// Trigger payment
const trigRes = await api('POST', `/purchase-orders/invoices/${state.invoiceId}/trigger-payment`, token);
expect(trigRes.status).toBe(200);
// Confirm payment
const poRes = await api('GET', `/purchase-orders/${state.poId}`, token);
const totalAmount = Number(poRes.data.totalAmount);
const payRes = await api('POST', `/purchase-orders/invoices/${state.invoiceId}/confirm-payment`, token, {
paidAmount: totalAmount,
paidAt: new Date().toISOString(),
});
expect(payRes.status).toBe(200);
});
// ═══════════════════════════════════════════════════════════════
// T-STD6-13 — UI verification: final state
// ═══════════════════════════════════════════════════════════════
test('T-STD6-13 — Vérification finale UI', async ({ page }) => {
await loginAs(page, 'admin');
// Check product is ACTIVE
await page.goto(`/products/${state.productId}`);
await expect(page.getByTestId('product-detail-page')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Активен')).toBeVisible();
// Check order exists in orders tab
await page.getByRole('tab', { name: 'Заказы' }).click();
await expect(page.getByTestId('pos-table')).toBeVisible({ timeout: 5000 });
await expect(page.getByText(state.orderCode).first()).toBeVisible({ timeout: 5000 });
// Navigate to order detail
await page.goto(`/orders/${state.orderId}`);
await expect(page.getByTestId('order-detail-page')).toBeVisible({ timeout: 10000 });
await expect(page.getByText(state.orderCode).first()).toBeVisible();
// Verify PO exists via API (final status check)
const token = await adminToken();
const poRes = await api('GET', `/purchase-orders/${state.poId}`, token);
expect(poRes.status).toBe(200);
expect(poRes.data.status).toBe('RECEIVED');
});
});