/** * Step-handlers для moysklad-import. * * Поднимаем mock MoySklad (lib/moysklad-mock.ts) на фиксированном порту, на * который заранее наведён API через MoySklad:BaseUrl. Прогоняем импорт через * реальные эндпоинты /api/admin/moysklad/* + фоновый job /api/admin/jobs/{id} * и проверяем результат и в job-счётчиках, и в БД (маппинг полей). * * Порт mock-сервера ДОЛЖЕН совпадать с MoySklad__BaseUrl, с которым запущен API * (см. tests/e2e/README / запуск): http://127.0.0.1:5099/api/remap/1.2/. */ import { login, makeClient } from '../lib/api.js' import { psql } from '../lib/db.js' import { generateEan13 } from '../lib/barcode.js' import { startMoySkladMock, type MockHandle, type MsDataset } from '../lib/moysklad-mock.js' import type { CheckResult, Step, Report } from '../lib/report.js' import type { AxiosInstance } from 'axios' const TS = Date.now() const MOCK_PORT = 5099 const MOCK_BASE = `http://127.0.0.1:${MOCK_PORT}/api/remap/1.2/` interface Ctx { apiOnly: boolean superAdminToken?: string adminToken?: string orgId?: string folderId?: string ean?: string countryName?: string countryId?: string } interface StepCtx { ctx: Ctx; step: Step; report: Report } let mock: MockHandle | undefined function check(step: Step, c: CheckResult) { step.checks.push(c) } function asString(x: unknown): string { if (x == null) return '' if (typeof x === 'string') return x try { return JSON.stringify(x).slice(0, 200) } catch { return String(x) } } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) function q1(sql: string): string { return (psql(sql).trim().split('\n')[0] ?? '').trim() } function cpField(orgId: string, name: string, col: string): string { return q1(`SELECT "${col}" FROM counterparties WHERE "OrganizationId"='${orgId}' AND "Name"='${name}'`) } function cpCount(orgId: string): number { return Number(q1(`SELECT count(*) FROM counterparties WHERE "OrganizationId"='${orgId}'`)) || 0 } function prodCount(orgId: string): number { return Number(q1(`SELECT count(*) FROM products WHERE "OrganizationId"='${orgId}'`)) || 0 } // Строит набор данных mock'а. label/phone варьируем для overwrite-теста. function buildDataset(ctx: Ctx, phoneSuffix = ''): MsDataset { return { organization: { name: `Mock Org ${TS}`, inn: '600700800900' }, counterparties: [ { name: `ТОО Ромашка ${TS}`, legalTitle: `Товарищество Ромашка ${TS}`, companyType: 'legal', inn: '123456789012', kpp: 'KPP001', phone: `+770111122${phoneSuffix || '33'}`, email: `romashka-${TS}@example.kz`, actualAddress: 'Алматы Абая 1', description: 'поставщик овощей', archived: false, }, { name: `ИП Иванов ${TS}`, companyType: 'entrepreneur', inn: '850101300123', phone: `+770233344${phoneSuffix || '55'}`, email: `ivanov-${TS}@example.kz`, legalAddress: 'Астана Победы 5', archived: false, }, ], folders: [ { id: ctx.folderId, name: `Бакалея ${TS}`, pathName: '', archived: false }, ], products: [ { id: `p-${TS}`, name: `Сахар-песок 1кг ${TS}`, article: `SUG-${TS}`, code: `C${TS}`, description: 'белый сахар', weighed: false, vat: 12, vatEnabled: true, archived: false, trackingType: 'NOT_TRACKED', barcodes: [{ ean13: ctx.ean }], salePrices: [ { value: 35000, priceType: { name: 'Цена продажи' } }, { value: 32000, priceType: { name: 'Розничная цена' } }, ], buyPrice: { value: 25000 }, productFolder: { meta: { href: `${MOCK_BASE}entity/productfolder/${ctx.folderId}`, type: 'productfolder' } }, country: { name: ctx.countryName }, }, ], } } async function waitJob(api: AxiosInstance, jobId: string, timeoutMs = 20000): Promise | null> { const start = Date.now() while (Date.now() - start < timeoutMs) { const r = await api.get(`/api/admin/jobs/${jobId}`) if (r.status === 200 && (r.data.status !== 'Running' || r.data.finishedAt)) return r.data await sleep(250) } return null } // --------------------------------------------------------------------------- export async function step01_bootstrap_and_connect({ ctx, step, report }: StepCtx) { const sa = (await login('admin@food-market.local', 'Admin12345!')).accessToken ctx.superAdminToken = sa const orgRes = await makeClient(sa).post('/api/super-admin/organizations', { org: { name: `MoySklad ${TS}`, countryCode: 'KZ', bin: null, address: null, phone: null, email: null, defaultCurrencyId: null, accountOwnerUserId: null, }, adminLastName: 'MS', adminFirstName: 'Admin', adminEmail: `moysklad-${TS}@example.kz`, adminPosition: null, }) if (orgRes.status !== 200) { report.bug({ step: '01', severity: 'critical', title: 'bootstrap орг', detail: asString(orgRes.data) }); return } ctx.orgId = orgRes.data.organization.id ctx.adminToken = (await login(orgRes.data.adminEmail, orgRes.data.adminTempPassword)).accessToken const api = makeClient(ctx.adminToken) // Реальное имя страны из справочника — для детерминированного маппинга country. const countries = await api.get('/api/catalog/countries?pageSize=300') const kz = (countries.data?.items ?? countries.data ?? []).find((c: { name: string }) => /Казахстан/i.test(c.name)) ?? (countries.data?.items ?? countries.data ?? [])[0] ctx.countryName = kz?.name ctx.countryId = kz?.id ctx.folderId = `f-${TS}` ctx.ean = generateEan13(1) // Mock + наведённый на него API. mock = await startMoySkladMock(MOCK_PORT) mock.setData(buildDataset(ctx)) check(step, { kind: 'api', description: 'Mock MoySklad поднят', ok: !!mock, detail: MOCK_BASE }) // Сохранение токена + маскирование. const token = `mocktoken${TS}abcd` const put = await api.put('/api/admin/moysklad/settings', { token }) check(step, { kind: 'api', description: 'PUT settings → hasToken + маска', ok: put.status === 200 && put.data.hasToken === true && /•/.test(put.data.masked ?? ''), detail: `status=${put.status} masked=${put.data?.masked}` }) const get = await api.get('/api/admin/moysklad/settings') check(step, { kind: 'api', description: 'GET settings не отдаёт сырой токен', ok: get.data.hasToken === true && !String(get.data.masked ?? '').includes(token), detail: `masked=${get.data?.masked}` }) // test-connection: API сходит в mock entity/organization. const test = await api.post('/api/admin/moysklad/test', {}) check(step, { kind: 'api', description: 'POST test → 200, имя орги из mock', ok: test.status === 200 && test.data.organization === `Mock Org ${TS}`, detail: `status=${test.status} org=${asString(test.data)}` }) if (test.status !== 200) report.bug({ step: '01', severity: 'critical', title: 'test-connection не дошёл до mock MoySklad', detail: `Проверь, что API запущен с MoySklad__BaseUrl=${MOCK_BASE}. Ответ: ${asString(test.data)}` }) } export async function step02_import_counterparties_create({ ctx, step, report }: StepCtx) { if (!ctx.orgId || !mock) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken!) const r = await api.post('/api/admin/moysklad/import-counterparties', { overwriteExisting: false }) check(step, { kind: 'api', description: 'import-counterparties → jobId', ok: r.status === 200 && !!r.data.jobId, detail: `status=${r.status}` }) if (!r.data?.jobId) return const job = await waitJob(api, r.data.jobId) check(step, { kind: 'api', description: 'job Succeeded', ok: job?.status === 'Succeeded', detail: `status=${job?.status} msg=${job?.message}` }) check(step, { kind: 'api', description: 'Created=2, Skipped=0', ok: job?.created === 2 && job?.skipped === 0, detail: `created=${job?.created} updated=${job?.updated} skipped=${job?.skipped}` }) check(step, { kind: 'db', description: 'В БД 2 контрагента', ok: cpCount(ctx.orgId) === 2, detail: `count=${cpCount(ctx.orgId)}` }) // Маппинг ТОО Ромашка (companyType=legal → LegalEntity=1). const ro = `ТОО Ромашка ${TS}` check(step, { kind: 'db', description: 'Ромашка: Type=1(LegalEntity), Bin=inn, TaxNumber=kpp', ok: cpField(ctx.orgId, ro, 'Type') === '1' && cpField(ctx.orgId, ro, 'Bin') === '123456789012' && cpField(ctx.orgId, ro, 'TaxNumber') === 'KPP001', detail: `Type=${cpField(ctx.orgId, ro, 'Type')} Bin=${cpField(ctx.orgId, ro, 'Bin')} Tax=${cpField(ctx.orgId, ro, 'TaxNumber')}` }) check(step, { kind: 'db', description: 'Ромашка: LegalName=legalTitle, Address=actualAddress, Notes=description', ok: cpField(ctx.orgId, ro, 'LegalName') === `Товарищество Ромашка ${TS}` && cpField(ctx.orgId, ro, 'Address') === 'Алматы Абая 1' && cpField(ctx.orgId, ro, 'Notes') === 'поставщик овощей', detail: `Legal=${cpField(ctx.orgId, ro, 'LegalName')} Addr=${cpField(ctx.orgId, ro, 'Address')}` }) // Маппинг ИП Иванов (entrepreneur → Individual=2, Address=legalAddress fallback). const iv = `ИП Иванов ${TS}` check(step, { kind: 'db', description: 'Иванов: Type=2(Individual), Address=legalAddress (fallback)', ok: cpField(ctx.orgId, iv, 'Type') === '2' && cpField(ctx.orgId, iv, 'Address') === 'Астана Победы 5', detail: `Type=${cpField(ctx.orgId, iv, 'Type')} Addr=${cpField(ctx.orgId, iv, 'Address')}` }) if (cpCount(ctx.orgId) !== 2) report.bug({ step: '02', severity: 'high', title: 'Импортировано не 2 контрагента', detail: `count=${cpCount(ctx.orgId)}` }) } export async function step03_import_counterparties_idempotent({ ctx, step, report }: StepCtx) { if (!ctx.orgId || !mock) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken!) const r = await api.post('/api/admin/moysklad/import-counterparties', { overwriteExisting: false }) const job = await waitJob(api, r.data.jobId) check(step, { kind: 'api', description: 'job Succeeded', ok: job?.status === 'Succeeded', detail: `status=${job?.status}` }) check(step, { kind: 'api', description: 'Skipped=2, Created=0 (идемпотентность по имени)', ok: job?.skipped === 2 && job?.created === 0, detail: `created=${job?.created} skipped=${job?.skipped}` }) check(step, { kind: 'db', description: 'Дублей нет — в БД по-прежнему 2', ok: cpCount(ctx.orgId) === 2, detail: `count=${cpCount(ctx.orgId)}` }) if (cpCount(ctx.orgId) !== 2) report.bug({ step: '03', severity: 'critical', title: 'Повторный импорт породил дубли контрагентов', detail: `count=${cpCount(ctx.orgId)}` }) } export async function step04_import_counterparties_overwrite({ ctx, step, report }: StepCtx) { if (!ctx.orgId || !mock) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken!) // Меняем телефоны в источнике и импортируем с overwrite=true. mock.setData(buildDataset(ctx, '99')) const r = await api.post('/api/admin/moysklad/import-counterparties', { overwriteExisting: true }) const job = await waitJob(api, r.data.jobId) check(step, { kind: 'api', description: 'job Succeeded', ok: job?.status === 'Succeeded', detail: `status=${job?.status}` }) check(step, { kind: 'api', description: 'Updated=2, Created=0', ok: job?.updated === 2 && job?.created === 0, detail: `created=${job?.created} updated=${job?.updated} skipped=${job?.skipped}` }) check(step, { kind: 'db', description: 'Телефон Ромашки обновлён на новый', ok: cpField(ctx.orgId, `ТОО Ромашка ${TS}`, 'Phone') === '+77011112299', detail: `phone=${cpField(ctx.orgId, `ТОО Ромашка ${TS}`, 'Phone')}` }) check(step, { kind: 'db', description: 'Кол-во не выросло (обновление, не вставка)', ok: cpCount(ctx.orgId) === 2, detail: `count=${cpCount(ctx.orgId)}` }) // Возвращаем исходные данные, чтобы дальнейшие шаги были предсказуемы. mock.setData(buildDataset(ctx)) } export async function step05_import_products_create({ ctx, step, report }: StepCtx) { if (!ctx.orgId || !mock) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken!) const r = await api.post('/api/admin/moysklad/import-products', { overwriteExisting: false }) check(step, { kind: 'api', description: 'import-products → jobId', ok: r.status === 200 && !!r.data.jobId, detail: `status=${r.status}` }) if (!r.data?.jobId) return const job = await waitJob(api, r.data.jobId) check(step, { kind: 'api', description: 'job Succeeded', ok: job?.status === 'Succeeded', detail: `status=${job?.status} msg=${job?.message} errors=${asString(job?.errors)}` }) check(step, { kind: 'api', description: 'Created=1, GroupsCreated>=1', ok: job?.created === 1 && (job?.groupsCreated ?? 0) >= 1, detail: `created=${job?.created} groups=${job?.groupsCreated}` }) const art = `SUG-${TS}` const pid = q1(`SELECT "Id" FROM products WHERE "OrganizationId"='${ctx.orgId}' AND "Article"='${art}'`) check(step, { kind: 'db', description: 'Товар создан с артикулом из MoySklad', ok: !!pid, detail: `id=${pid}` }) if (!pid) { report.bug({ step: '05', severity: 'high', title: 'Товар не импортирован', detail: `errors=${asString(job?.errors)}` }); return } const vat = q1(`SELECT "Vat" FROM products WHERE "Id"='${pid}'`) const pack = q1(`SELECT "Packaging" FROM products WHERE "Id"='${pid}'`) const marked = q1(`SELECT "IsMarked" FROM products WHERE "Id"='${pid}'`) const ref = q1(`SELECT "ReferencePrice" FROM products WHERE "Id"='${pid}'`) check(step, { kind: 'db', description: 'Vat=12, Packaging=1(Piece, weighed=false), IsMarked=false', ok: Number(vat) === 12 && pack === '1' && (marked === 'f' || marked === 'false'), detail: `vat=${vat} pack=${pack} marked=${marked}` }) check(step, { kind: 'db', description: 'ReferencePrice = buyPrice/100 = 250', ok: Number(ref) === 250, detail: `ref=${ref}` }) // Группа из productFolder. const grpName = q1(`SELECT g."Name" FROM products p JOIN product_groups g ON g."Id"=p."ProductGroupId" WHERE p."Id"='${pid}'`) check(step, { kind: 'db', description: 'Группа = productFolder «Бакалея»', ok: grpName === `Бакалея ${TS}`, detail: `group=${grpName}` }) // Страна по имени. const country = q1(`SELECT "CountryOfOriginId" FROM products WHERE "Id"='${pid}'`) check(step, { kind: 'db', description: 'CountryOfOrigin сопоставлена по имени', ok: !!country && country === ctx.countryId, detail: `country=${country} expected=${ctx.countryId}` }) // Розничная цена = salePrice «Розничная» / 100 = 320. const price = q1(`SELECT "Amount" FROM product_prices WHERE "ProductId"='${pid}'`) check(step, { kind: 'db', description: 'Розничная цена = 320 (salePrice «Розничная»/100)', ok: Number(price) === 320, detail: `amount=${price}` }) // Штрихкод. const bc = q1(`SELECT "Code" FROM product_barcodes WHERE "ProductId"='${pid}'`) check(step, { kind: 'db', description: 'Штрихкод ean13 импортирован', ok: bc === ctx.ean, detail: `bc=${bc} expected=${ctx.ean}` }) } export async function step06_import_products_idempotent({ ctx, step, report }: StepCtx) { if (!ctx.orgId || !mock) { step.status = 'skip'; return } const api = makeClient(ctx.adminToken!) const before = prodCount(ctx.orgId) const r = await api.post('/api/admin/moysklad/import-products', { overwriteExisting: false }) const job = await waitJob(api, r.data.jobId) check(step, { kind: 'api', description: 'job Succeeded', ok: job?.status === 'Succeeded', detail: `status=${job?.status}` }) check(step, { kind: 'api', description: 'Skipped=1, Created=0 (идемпотентность по артикулу)', ok: job?.skipped === 1 && job?.created === 0, detail: `created=${job?.created} skipped=${job?.skipped}` }) check(step, { kind: 'db', description: 'Кол-во товаров не изменилось (дублей нет)', ok: prodCount(ctx.orgId) === before, detail: `before=${before} after=${prodCount(ctx.orgId)}` }) if (prodCount(ctx.orgId) !== before) report.bug({ step: '06', severity: 'critical', title: 'Повторный импорт породил дубль товара', detail: `before=${before} after=${prodCount(ctx.orgId)}` }) } export async function step07_cleanup({ step }: StepCtx) { if (mock) { await mock.close(); mock = undefined } check(step, { kind: 'api', description: 'Mock MoySklad остановлен', ok: true }) }