docs(migration): инвентаризация источника МойСклад + дизайн мигратора (Part 1)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run

Решение владельца 09.10: полный перенос магазина из МойСклада в ALL MARKET с историей.
- scripts/migration/ms_inventory.py — read-only инвентаризация источника.
- docs/migration-source-inventory.md: орг «FOOD MARKET», ~31k товаров, 627 контрагентов,
  ~110k документов (97 707 чеков, 7 125 приёмок, 3 068 списаний…), 7 558 строк остатков,
  1 склад/точка/орг, касса наличных, 1 тип цены; gap-анализ модели + цели сверки.
- docs/migration-design.md: идемпотентность по msId, хронология документов, МСК→UTC,
  тиыны, резюмируемость, порядок этапов, сверка, дельта, runbook.
- docs/migration-progress.md: план Part 1–7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 13:26:35 +05:00
parent 2b751dc129
commit a2ee85bc12
4 changed files with 259 additions and 0 deletions

62
docs/migration-design.md Normal file
View file

@ -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: спроектированы; реализация + прогон (часы) — продолжение после перехода-инфраструктуры.

View file

@ -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. В МойСклад — только чтение.

View file

@ -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 подтвердит.

View file

@ -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()