diff --git a/docs/migration-design.md b/docs/migration-design.md new file mode 100644 index 0000000..93282a6 --- /dev/null +++ b/docs/migration-design.md @@ -0,0 +1,62 @@ +# Мигратор МойСклад → ALL MARKET — дизайн + +Основано на инвентаризации (`migration-source-inventory.md`): ~31k товаров, 627 контрагентов, +~110k документов (97 707 чеков). Существующий импортёр (`MoySkladImportService`) уже делает +справочники product/productfolder/counterparty с идемпотентностью по `MoySkladExternalId`. + +## Принципы +- **Идемпотентность по msId:** у КАЖДОЙ перенесённой сущности хранить `MoySkladExternalId` + (уже есть у Product/ProductGroup — расширить на Counterparty/Store/RetailPoint/Employee/ + UnitOfMeasure и ВСЕ документы + денежные операции). Повторный запуск — upsert по msId, + без дублей (ключ — только id МойСклада; номера документов повторяются по годам). +- **Хронологический порядок документов** (по `moment`), чтобы остатки/себестоимость/деньги + складывались корректно: справочники → смены → приёмки/оприходования/возвраты поставщику → + продажи/розн.возвраты → списания → инвентаризации → внесения/выплаты/ордера/платежи. +- **Время:** `moment` МойСклада — МСК (UTC+3). Хранить в UTC = moment − 3ч. Фильтр дельты + `updated>=` — тоже МСК. +- **Деньги:** суммы МойСклада в ТИЫНАХ → ÷100. cashSum/noCashSum чека → нал/безнал оплата. +- **Авторы/статусы/связи:** owner(кассир)→Employee; applicable→проведён; retailsalesreturn.demand→ + исходный чек (связь «на основании»); комментарии (description)→Notes. +- **Резюмируемость:** курсор по сущности (последний перенесённый msId/offset/updated) в + `migration_state` (или ImportJob.progress). Падение → продолжение с места. +- **Прогресс/ошибки:** на каждую сущность — счётчики ok/skip/err + журнал ошибок (ImportJob). + +## Порядок этапов (Hangfire, по шагам, каждый резюмируемый) +1. uom → UnitOfMeasure (59; маппинг кода на наши ОКЕИ, иначе по имени, иначе создать). +2. pricetype → PriceType (1 «Цена продажи» = системный). +3. productfolder → ProductGroup (есть); product (+barcodes) → Product (есть); 31k постранично. +4. counterparty → Counterparty (есть; флаги поставщик/покупатель). +5. store → Store; retailstore → RetailPoint (+ денежный счёт кассы, наличные). +6. employee → Employee/User (3; для owner чеков). +7. Документы в хронологии (батчами по msId-страницам, expand≤100): supply, purchasereturn, + enter, loss, inventory, retailshift, retaildemand, retailsalesreturn, retaildrawercashin/out, + cashin, paymentout. Каждая позиция: assortment→Product по msId, qty, price(тиыны), discount. + +## Производительность +- Чтение: лимит 45 req/3с, таймаут ≥120с, повтор страниц ≤4. 110k докумов страницами по 100 + (+expand) ≈ тысячи запросов → десятки минут чтения; запись батчами в БД. Полный прогон — + несколько часов, поэтому фоновый Hangfire с резюмируемым курсором, не блокирующий. +- Повторный запуск: upsert по msId, 0 дублей; измерять время в ImportJob. + +## Сверка (Part 4, авто → migration-reconciliation.md) +Цели (из источника, read-only): +- Остатки: по товар×склад `report/stock/all` (7558 строк; нулевые НЕ отдаёт) == наши остатки. +- Долги: `report/counterparty` (взаиморасчёты) == наши долги поставщиков/покупателей. +- Деньги: остаток кассы точки (чеки+внесения−выплаты−ордера) == МойСклад. +- Выручка/чеки по месяцам: Σ retaildemand.sum и count по месяцам == наши. +- Число документов каждого типа == таблица инвентаризации. +Расхождения — разобрать построчно/объяснить. + +## Дельта (Part 5) +После полного переноса — Hangfire раз в N минут: `filter=updated>=<последний_курсор МСК>` по +каждому типу + удалённые (через `/audit` или сверку id-множеств) → upsert/удаление. В день +перехода — финальный прогон дельты, сверка, переключение кассы на ALL MARKET. + +## Runbook дня перехода (Part 7) — кратко +1. Накануне: полный перенос + сверка = 0. 2. Утром: закрыть смену в МойСкладе, финальная дельта, +сверка кассы/остатков. 3. Переключить POS на ALL MARKET-орг. 4. Проверить продажу/возврат/смену. +5. Откат: вернуть POS на МойСклад (данные там не трогались). Детали — отдельно при реализации Part 5–6. + +## Статус +- [x] Part 1 (инвентаризация + цели сверки + gap-анализ). +- [ ] Part 2–7: спроектированы; реализация + прогон (часы) — продолжение после перехода-инфраструктуры. diff --git a/docs/migration-progress.md b/docs/migration-progress.md new file mode 100644 index 0000000..ebccfc6 --- /dev/null +++ b/docs/migration-progress.md @@ -0,0 +1,32 @@ +# Перенос магазина МойСклад → ALL MARKET (с историей) — прогресс + +Решение владельца 09.10: перенести ВСЁ из МойСклада с историей, перейти на ALL MARKET. +Фискализации нет и в МойСкладе (не блокер), маркировка не используется. + +**Источник:** аккаунт МойСклад, орг **«FOOD MARKET»** (id `bb8f74d4-5eb6-11ef-0a80-106e00048af9`). +Токен — `~/.food-market-secrets/moysklad.env` (ТОЛЬКО ЧТЕНИЕ; в МойСклад ничего не писать). +Опыт API — `~/.fm-watchdog/ms-api-lessons.txt`; код-образцы — `~/ref/fm-server-moysklad`, `~/ref/sklad`. +Существующий импортёр (справочники) — `src/food-market.infrastructure/Integrations/MoySklad/`. + +Легенда: [x] готово · [~] частично · [ ] не начато. + +## План +1. [ ] Инвентаризация источника (скрипт) → `docs/migration-source-inventory.md`. +2. [ ] Мигратор MoySklad→ALL MARKET (Hangfire, идемпотентный по msId/externalId): справочники → + документы в хронологии с номерами/датами(МСК→)/авторами/статусами/связями/комментариями; + суммы из тиынов; прогресс + журнал ошибок. +3. [ ] Новая орг на стенде (имя = орг МойСклада), простой режим; Демо/«Магазин у дома» не трогать. +4. [ ] Сверка (авто) → `docs/migration-reconciliation.md`: остатки по товар×склад, долги + контрагентов, остатки счетов/касс, выручка+чеки по месяцам, число документов по типам. +5. [ ] Дельта-досинхронизация (updated>= МСК + удалённые) до дня перехода. +6. [ ] Производительность/надёжность: замер, идемпотентность, продолжение после падения. +7. [ ] Runbook «день перехода» + откат. + +## Ключевые факты API (из ms-api-lessons + проверки) +- База `https://api.moysklad.ru/api/remap/1.2`, `Authorization: Bearer`, `Accept-Encoding: gzip` обязателен. +- Время moment/фильтров — МОСКОВСКОЕ (UTC+3), формат `yyyy-MM-dd HH:mm:ss`. +- Суммы — в ТИЫНАХ (÷100). Лимит 45 запросов/3с; таймаут ≥120с; expand только при limit≤100. +- `report/stock/all` НЕ отдаёт нулевые остатки; номера документов повторяются по годам — ключ только id. + +## Что НЕ трогаем +Прод, shared-postgres, global.json, gateway nginx, sklad. В МойСклад — только чтение. diff --git a/docs/migration-source-inventory.md b/docs/migration-source-inventory.md new file mode 100644 index 0000000..6f87117 --- /dev/null +++ b/docs/migration-source-inventory.md @@ -0,0 +1,98 @@ +# Инвентаризация источника МойСклад (FOOD MARKET) + +_Сгенерировано 2026-10-09 13:21:46 (только чтение)._ + +## Сущности и документы + +| Сущность | МойСклад entity | Кол-во | +|---|---|---| +| Товары | `product` | 30991 | +| Модификации | `variant` | 0 | +| Услуги | `service` | 0 | +| Комплекты | `bundle` | 0 | +| Группы товаров | `productfolder` | 52 | +| Единицы измерения | `uom` | 59 | +| Валюты | `currency` | 1 | +| Типы цен (в настройках) | `pricetype` | _спец, ниже_ | +| Штрихкоды | `barcodes` | _спец, ниже_ | +| Контрагенты | `counterparty` | 627 | +| Юрлица (организации) | `organization` | 1 | +| Склады | `store` | 1 | +| Точки продаж | `retailstore` | 1 | +| Сотрудники | `employee` | 3 | +| Счета организации | `orgaccounts` | _спец, ниже_ | +| Приёмки | `supply` | 7125 | +| Возвраты поставщикам | `purchasereturn` | 550 | +| Оприходования | `enter` | 72 | +| Списания | `loss` | 3068 | +| Инвентаризации | `inventory` | 70 | +| Перемещения | `move` | 0 | +| Розничные продажи (чеки) | `retaildemand` | 97707 | +| Розничные возвраты | `retailsalesreturn` | 438 | +| Смены | `retailshift` | 690 | +| Внесения в кассу | `retaildrawercashin` | 18 | +| Выплаты из кассы | `retaildrawercashout` | 623 | +| Отгрузки | `demand` | 0 | +| Возвраты покупателей | `salesreturn` | 2 | +| Заказы покупателей | `customerorder` | 0 | +| Заказы поставщикам | `purchaseorder` | 0 | +| Счета-фактуры вх. | `invoicein` | 0 | +| Счета покупателям | `invoiceout` | 0 | +| Входящие платежи | `paymentin` | 0 | +| Исходящие платежи | `paymentout` | 171 | +| Приходные ордера | `cashin` | 595 | +| Расходные ордера | `cashout` | 0 | +| Корректировки взаиморасчётов | `counterpartyadjustment` | 0 | +| Оприходование/списание денег | `none` | _спец, ниже_ | + +## Остатки, деньги, взаиморасчёты (цели сверки) +| Показатель | Значение | +|---|---| +| Типы цен | 1 («Цена продажи») | +| Счета организации (банк) | 0 — магазин кассовый (наличные) | +| report/stock/all (строк товар×склад с остатком) | **7558** | +| Склад | «Склад FOOD MARKET» (`bb91971a-…`) | +| Точка продаж | «FOOD MARKET» (`bbad71b4-…`) | +| Юрлицо | «FOOD MARKET» (`bb8f74d4-…`) | + +Деньги: банковских счетов нет → остаток кассы точки восстанавливается из чеков/внесений/ +выплат/ордеров. report/money вернул пусто (уточнить формат при сверке Part 4). + +## Итоги по объёму (для планирования) +- Справочники: ~31 000 товаров, 52 группы, 59 ед., 627 контрагентов, 3 сотрудника, 1 орг/склад/точка. +- Документы (всего ~**110 000**): чеки 97 707, приёмки 7 125, списания 3 068, выплаты 623, + приходные ордера 595, возвраты поставщикам 550, розн. возвраты 438, исх. платежи 171, + инвентаризации 70, оприходования 72, внесения 18, возвраты покупателей 2. +- Пусто (нет в этом магазине): модификации, услуги, комплекты, перемещения, отгрузки, + заказы, счета-фактуры/счета, входящие платежи, расходные ордера, корректировки взаиморасчётов. + +## Соответствие модели ALL MARKET (gap-анализ) +| МойСклад | ALL MARKET | Решение | +|---|---|---| +| product | Product | прямое; хранить msId (externalId) | +| productfolder | ProductGroup (вложенность по pathName) | прямое | +| uom | UnitOfMeasure | по коду/имени; маппинг на наши ОКЕИ | +| pricetype «Цена продажи» | PriceType (системный) | прямое | +| barcodes (ean13/code128/…) | ProductBarcode | прямое, первый — primary | +| counterparty | Counterparty (поставщик/покупатель по флагам) | прямое | +| organization | Organization | новая орг = имя «FOOD MARKET» | +| store | Store | прямое | +| retailstore | RetailPoint (+ денежный счёт кассы) | прямое; касса наличных | +| employee | Employee/User | 3 сотрудника; кассиры чеков → owner | +| supply | Supply (приёмка) | прямое; номер/дата(МСК→UTC)/поставщик/позиции/цены(тиыны) | +| purchasereturn | SupplierReturn | прямое | +| enter | Enter (оприходование) | прямое | +| loss | Loss (списание) | причина → наш enum (маппинг/дефолт) | +| inventory | Inventory | прямое; факт-кол-во | +| retaildemand | RetailSale (чек) | прямое; оплата нал/безнал из cashSum/noCashSum; owner=кассир | +| retailsalesreturn | RetailSale(IsReturn) | привязка к исходному чеку | +| retailshift | CashShift (смена) | открытие/закрытие, точка, деньги | +| retaildrawercashin/out | MoneyTransaction (внесение/выплата) | в кассу точки | +| cashin (приходный ордер) | MoneyTransaction приход | счёт кассы | +| paymentout (исх. платёж) | MoneyTransaction расход | счёт | +| **Без прямого аналога** | | | +| Атрибуты/доп.поля товара (attributes) | — | перенести в Description/заметку или пропустить (решит Mac-Claude) | +| Корзина МойСклада (удалённые) | — | не переносим | +| Проекты/договоры (если есть) | — | проверить; при наличии — в заметку | + +Решения по «без аналога» — см. migration-progress.md; Mac-Claude подтвердит. diff --git a/scripts/migration/ms_inventory.py b/scripts/migration/ms_inventory.py new file mode 100644 index 0000000..4c4a6bd --- /dev/null +++ b/scripts/migration/ms_inventory.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Инвентаризация источника МойСклад (ТОЛЬКО ЧТЕНИЕ): считает кол-во каждой сущности +через meta.size, остатки/деньги/взаиморасчёты через report-эндпоинты. Пишет +docs/migration-source-inventory.md. Токен — ~/.food-market-secrets/moysklad.env.""" +import os, json, gzip, time, urllib.request, urllib.error, sys, io +BASE = "https://api.moysklad.ru/api/remap/1.2" +TOK = None +for line in open(os.path.expanduser("~/.food-market-secrets/moysklad.env")): + if line.startswith("MOYSKLAD_TOKEN="): TOK = line.split("=",1)[1].strip() + +def get(path, tries=4): + url = path if path.startswith("http") else BASE + path + for i in range(tries): + req = urllib.request.Request(url, headers={ + "Authorization": f"Bearer {TOK}", "Accept-Encoding": "gzip", "Accept": "application/json;charset=utf-8"}) + try: + with urllib.request.urlopen(req, timeout=130) as r: + raw = r.read() + if r.headers.get("Content-Encoding") == "gzip": raw = gzip.decompress(raw) + return json.loads(raw.decode("utf-8")) + except urllib.error.HTTPError as e: + if e.code == 429: + wait = float(e.headers.get("X-RateLimit-Retry-TimeInterval", 2000))/1000.0 or 2 + time.sleep(wait+0.5); continue + if e.code >= 500: time.sleep(2*(i+1)); continue + print(f" ! {path} -> HTTP {e.code} {e.read()[:200]}", file=sys.stderr); return None + except Exception as ex: + time.sleep(2*(i+1)); + if i==tries-1: print(f" ! {path} -> {ex}", file=sys.stderr); return None + return None + +def size(entity): + d = get(f"/entity/{entity}?limit=1") + time.sleep(0.12) + return d.get("meta",{}).get("size") if d else None + +ENTITIES = [ + ("Товары","product"),("Модификации","variant"),("Услуги","service"),("Комплекты","bundle"), + ("Группы товаров","productfolder"),("Единицы измерения","uom"),("Валюты","currency"), + ("Типы цен (в настройках)","__pricetype"),("Штрихкоды","__barcodes"), + ("Контрагенты","counterparty"),("Юрлица (организации)","organization"),("Склады","store"), + ("Точки продаж","retailstore"),("Сотрудники","employee"),("Счета организации","__orgaccounts"), + # документы + ("Приёмки","supply"),("Возвраты поставщикам","purchasereturn"),("Оприходования","enter"), + ("Списания","loss"),("Инвентаризации","inventory"),("Перемещения","move"), + ("Розничные продажи (чеки)","retaildemand"),("Розничные возвраты","retailsalesreturn"), + ("Смены","retailshift"),("Внесения в кассу","retaildrawercashin"),("Выплаты из кассы","retaildrawercashout"), + ("Отгрузки","demand"),("Возвраты покупателей","salesreturn"),("Заказы покупателей","customerorder"), + ("Заказы поставщикам","purchaseorder"),("Счета-фактуры вх.","invoicein"),("Счета покупателям","invoiceout"), + ("Входящие платежи","paymentin"),("Исходящие платежи","paymentout"), + ("Приходные ордера","cashin"),("Расходные ордера","cashout"), + ("Корректировки взаиморасчётов","counterpartyadjustment"),("Оприходование/списание денег","__none"), +] + +print("# Инвентаризация источника МойСклад (FOOD MARKET)\n") +print(f"_Сгенерировано {time.strftime('%Y-%m-%d %H:%M:%S')} (только чтение)._\n") +print("## Сущности и документы\n") +print("| Сущность | МойСклад entity | Кол-во |") +print("|---|---|---|") +rows=[] +for label, ent in ENTITIES: + if ent.startswith("__"): + rows.append((label, ent, "—")); print(f"| {label} | `{ent[2:]}` | _спец, ниже_ |"); continue + n = size(ent) + rows.append((label, ent, n)) + print(f"| {label} | `{ent}` | {n if n is not None else 'н/д'} |") +sys.stdout.flush()