lib/moysklad-mock.ts — минимальный mock JSON-API remap 1.2 (organization/ counterparty/product/productfolder) с полями по MoySkladDtos. Сценарий (7 шагов): сохранение/маскирование токена, test-connection, импорт контрагентов и товаров через фоновый job, идемпотентность повторного импорта (overwrite=false → Skipped), обновление по ключу (overwrite=true → Updated), и проверка маппинга в БД (BIN/тип/адрес контрагента; артикул/НДС/упаковка/ цена/штрихкод/группа/страна товара). Требует запуск API с MoySklad__BaseUrl=http://127.0.0.1:5099/api/remap/1.2/. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
272 lines
17 KiB
TypeScript
272 lines
17 KiB
TypeScript
/**
|
||
* 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<Record<string, any> | 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 })
|
||
}
|