diff --git a/docs/migration-progress.md b/docs/migration-progress.md index fb3ee38..30315bf 100644 --- a/docs/migration-progress.md +++ b/docs/migration-progress.md @@ -35,10 +35,19 @@ - [x] Списания (loss, −остаток), возвраты поставщику (purchasereturn, −остаток) — мигратор написан по шаблону (запуск ПОСЛЕ retaildemand — параллельно нельзя, гонка остатков по одной орг). - - [ ] retailsalesreturn (связь с чеком по externalId), inventory, retailshift, - внесения/выплаты/ордера/платежи — по шаблону (следующая итерация). - - Эндпоинт `POST /import-supplies?type=enter|loss|purchasereturn|retaildemand` (фон). + - [x] **retailsalesreturn** (возврат розницы, связь с чеком по externalId demand, +остаток), + **inventory** (Completed без движений, BookQty/ActualQty из calculated/quantity) — в сборке. + - [x] **Деньги** (`type=money`): paymentout/paymentin/cashin/cashout/retaildrawercashin/out → + `MoneyTransaction` (история), agent→контрагент по externalId, ÷100, МСК→UTC. Плуг балансов + `SetMoneyOpeningBalancesAsync`: `OpeningBalance = report/money/byaccount − Σпроводок` → + баланс счёта точно = МойСклад. Счета «Касса (из МойСклад)» + «Расчётный счёт (из МойСклад)». + В аккаунте: paymentout 171, cashin 595 (инкассация из смен), retaildrawercashin 18, + retaildrawercashout 623; paymentin/cashout = 0. + - Эндпоинт `POST /import-supplies?type=…|retaildemand|retailsalesreturn|inventory|money|balances` (фон). ВАЖНО: запускать типы ПОСЛЕДОВАТЕЛЬНО (не параллельно — одна орг, гонка stocks). + - [x] **Дельта** (`?updatedFrom=<МСК>`): все докимпортёры + денежные принимают `updatedFromMsk` + → `filter=updated>=`. Драйвер `scripts/migration/delta_sync.py` (cron раз в N мин, poll + job'ов, удаления через `/audit`, состояние в `~/.fm-watchdog/migration-delta-state.json`). - [ ] uom/склад/точка/сотрудники как отдельные справочники с externalId (пока товары на дефолтных ед.; склад — единственный склад орг). 3. [x] **Новая орг на стенде** «FOOD MARKET» (простой режим), admin `foodmarket-migr@food-market.local`. @@ -49,9 +58,12 @@ розничные продажи — перенос идёт (17 400 из ~97 731). Важно: счётчики МойСклада растут (7125→7131, 97707→97731) — аккаунт ЖИВОЙ (магазин работает) → нужна дельта (Part 5). Остатки/долги/выручка-по-месяцам — секции после завершения продаж. -5. [ ] Дельта-досинхронизация (updated>= МСК + удалённые). -6. [ ] Производительность/надёжность (резюмируемый курсор документов). -7. [ ] Runbook «день перехода» + откат. +5. [x] **Дельта-досинхронизация** (`updated>=` МСК + удалённые через `/audit`) — + `scripts/migration/delta_sync.py`; проверка «изменения появляются» — после деплоя. +6. [x] **Производительность/надёжность** — идемпотентность по `MoySkladExternalId`, резюм по + `?offset=N`, per-doc SaveChanges+ChangeTracker.Clear (в т.ч. в catch) → сбой не каскадит. + Задокументировано в рунбуке (Part 6). +7. [x] **Runbook «день перехода»** + откат → `docs/migration-runbook.md`. ## Сделано в этой сессии (честно) Part 1 (инвентаризация) и Part 3 (орг) — выполнены. Part 2 (справочники) — пайплайн проверен diff --git a/docs/migration-runbook.md b/docs/migration-runbook.md new file mode 100644 index 0000000..c13f277 --- /dev/null +++ b/docs/migration-runbook.md @@ -0,0 +1,90 @@ +# Рунбук «День перехода» — МойСклад → ALL MARKET + +Решение владельца 09.10.2026: перенести ВСЁ из МойСклада с историей и продолжить работу +в ALL MARKET. Этот документ — пошаговый регламент перехода, кто что делает, как откатиться +и что проверить после. Фискализации/маркировки нет (не блокеры). + +- **Источник:** аккаунт МойСклад, орг **«FOOD MARKET»** (`bb8f74d4-5eb6-11ef-0a80-106e00048af9`). **Только чтение.** +- **Приёмник:** ALL MARKET, орг «FOOD MARKET» на стенде (простой режим), + admin `foodmarket-migr@food-market.local`. Демо-магазин и «Магазин у дома» НЕ трогаем. +- Токен МойСклада — `~/.food-market-secrets/moysklad.env` (в МойСклад ничего не писать). + +--- + +## Фаза A. Заранее (за несколько дней до перехода) + +1. **Полный перенос истории** (один раз, многочасовой). Запускать ПОСЛЕДОВАТЕЛЬНО (одна орг, + гонка остатков при параллельном запуске): + - Справочники: товары, группы, контрагенты (несут `MoySkladExternalId`). + - Документы (хронология, МСК→UTC, суммы ÷100, авторы/статусы): `supply` → `enter` → + `loss` → `purchasereturn` → `retaildemand` → `retailsalesreturn` → `inventory`. + - Деньги + плуг балансов: `type=money` (платежи/ордера/внесения/выплаты + OpeningBalance). + - Эндпоинт: `POST /api/admin/moysklad/import-supplies?type=[&offset=N]`. +2. **Сверка** (`scripts/migration/reconcile.py` → `docs/migration-reconciliation.md`): + число документов, остатки товар×склад, выручка+чеки по месяцам, балансы счетов, долги. + Все расхождения = 0 или объяснены построчно. +3. **Включить дельту** (`scripts/migration/delta_sync.py`, cron раз в 10–15 мин) с момента + окончания полного переноса: подтягивает только изменения `updated>=lastSync`. Проверить, + что свежие чеки из МойСклада появляются в ALL MARKET в течение интервала. + +## Фаза B. День перехода (час X) + +| Шаг | Кто | Действие | +|---|---|---| +| B1 | Владелец | Объявить стоп-кассе в МойСкладе: последний чек пробит, новые продажи приостановлены на ~15 мин. | +| B2 | Инженер | Остановить cron дельты (чтобы зафиксировать окно). | +| B3 | Инженер | Финальная дельта вручную: `python3 scripts/migration/delta_sync.py` — подтянет последние чеки/платежи за минуты. | +| B4 | Инженер | Обработать удаления из отчёта дельты (`lastDeletions` в состоянии): удалить соответствующие документы в ALL MARKET по регламенту (сверять по `MoySkladExternalId`). | +| B5 | Инженер | Прогнать сверку `reconcile.py`. Убедиться: документы, остатки, выручка, балансы = 0 расхождений (долги — см. ограничение токена ниже). | +| B6 | Владелец+Инженер | Контрольная ручная проверка в UI: 3–5 товаров (остаток), 2–3 поставщика (долг), баланс кассы и расчётного счёта, выручка за вчера. | +| B7 | Владелец | Переключить персонал/кассы на ALL MARKET (POS → адрес нового сервера, логины кассиров). | +| B8 | Владелец | Возобновить продажи в ALL MARKET. МойСклад переводится в режим «только архив» (не продавать). | + +## Фаза C. После перехода (контроль) + +- Первый день: следить за остатками/выручкой в ALL MARKET, сверять итоги дня с ожиданиями. +- Держать аккаунт МойСклада доступным на чтение ≥1 месяц (архив истории, разбор спорных). +- Не удалять токен/данные МойСклада, пока переход не признан успешным. + +--- + +## Откат (rollback) — вернуться на МойСклад + +Переход спроектирован как **неразрушающий для источника**: в МойСклад мы НИЧЕГО не писали, +поэтому откат = продолжить работать в МойСкладе. + +1. Владелец объявляет возврат на МойСклад; кассиры переключают POS обратно на МойСклад/старую кассу. +2. Продажи, совершённые в ALL MARKET за время после часа X, переносятся в МойСклад вручную + (их немного — окно короткое) ИЛИ принимается, что они останутся только в ALL MARKET как + параллельный период (по решению владельца). +3. Орг ALL MARKET на стенде остаётся как есть (данные не удаляются) — для повторной попытки. +4. Причина отката фиксируется; дефект устраняется; повторный день перехода назначается заново. + +**Критерий «не откатываемся»:** сверка B5 = 0 расхождений (или объяснены) и ручная проверка +B6 совпала. Если расходится критично (остатки/деньги) — откат по пунктам выше. + +--- + +## Надёжность и производительность (Part 6) + +- **Идемпотентность:** повторный запуск любого типа пропускает уже перенесённое по + `MoySkladExternalId` (дублей нет). Проверено на приёмках/оприходованиях/списаниях. +- **Резюмируемость:** падение/рестарт в середине — продолжить с `?offset=N` (N ≈ число уже + перенесённых минус 200 для перекрытия); идемпотентность убирает повтор. +- **Устойчивость к сбоям:** каждый документ — отдельный `SaveChanges` + `ChangeTracker.Clear` + (в т.ч. в catch), поэтому ошибка одного документа не роняет прогон и не каскадит. +- **Лимиты МойСклада:** 45 запросов/3с, таймаут 130с, `Accept-Encoding: gzip`, 429 с + повтором по `X-RateLimit-Retry-TimeInterval`. Страница 100 док., `order=moment,asc`. + +## Известные ограничения (объяснённые расхождения) + +- **Долги контрагентов:** отчёт МойСклада `report/counterparty` недоступен выданным read-only + токеном (**403** — нет права на взаиморасчёты). Долги в ALL MARKET формируются из + перенесённых первичных документов; итоговую цифру сверять вручную в UI МойСклада (шаг B6) + или временно выдать токену право на отчёт для авто-сверки `reconcile.py` (секция 5). +- **Балансы счетов:** розничная наличность/движения кассовых смен до перехода не + материализуются подокументно; баланс сводится «плугом» `OpeningBalance = баланс МойСклада + (report/money/byaccount) − Σ перенесённых проводок`. Текущий баланс счёта в ALL MARKET + точно совпадает с МойСкладом; подробная история доналичных движений остаётся в архиве МойСклада. +- **Нулевые остатки:** `report/stock/all` не отдаёт нулевые позиции — товар без строки + считается остатком 0 (сверка это учитывает). diff --git a/scripts/migration/delta_sync.py b/scripts/migration/delta_sync.py new file mode 100644 index 0000000..5cab06f --- /dev/null +++ b/scripts/migration/delta_sync.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Дельта-досинхронизация МойСклад → ALL MARKET (Part 5). Запускать раз в N минут +(cron / watchdog) до дня перехода. Только ЧТЕНИЕ МойСклада. + +Логика: + 1. Берёт lastSync (МСК) из файла состояния (при первом запуске — аргумент --since). + 2. Для каждого типа документов дергает фоновый импорт с filter=updated>=lastSync + (эндпоинт POST /api/admin/moysklad/import-supplies?type=..&updatedFrom=..), + последовательно (не параллельно — одна орг, гонка остатков), поллит job до конца. + 3. Удаления: /audit?filter=eventType=delete;moment>=lastSync — логируем (список id; + ручное удаление в ALL MARKET на дне перехода, т.к. автоудаление документов с + движениями рискованно и выполняется по регламенту рунбука). + 4. Обновляет lastSync = момент старта прогона (МСК), чтобы следующий прогон взял + только новые изменения. Перекрытие в 2 мин для надёжности на границе. + +Идемпотентность гарантируется мигратором (skip по MoySkladExternalId), поэтому +перекрытие окна не создаёт дублей. +""" +import os, json, time, ssl, sys, urllib.request, urllib.parse, urllib.error +from datetime import datetime, timedelta, timezone + +AM = os.environ.get("AM_URL", "https://test.admin.food-market.kz") +STATE = os.environ.get("DELTA_STATE", os.path.expanduser("~/.fm-watchdog/migration-delta-state.json")) +USER = os.environ.get("AM_USER", "foodmarket-migr@food-market.local") +PASS = os.environ.get("AM_PASS", "Migrate12345!") +MSTOK = [l.split("=", 1)[1].strip() for l in open(os.path.expanduser("~/.food-market-secrets/moysklad.env")) if l.startswith("MOYSKLAD_TOKEN=")][0] +MS = "https://api.moysklad.ru/api/remap/1.2" +_ctx = ssl.create_default_context(); _ctx.check_hostname = False; _ctx.verify_mode = ssl.CERT_NONE +MSK = timezone(timedelta(hours=3)) + +# Документные типы в порядке безопасного переноса (справочники отдельно не трогаем в +# дельте — их меняют редко; при необходимости добавить products/counterparties). +DOC_TYPES = ["supply", "enter", "loss", "purchasereturn", "retaildemand", "retailsalesreturn", "inventory", "money"] +# МойСкладовские entityType для аудита удалений (money раскрывается на под-типы ниже). +AUDIT_ENTITIES = ["supply", "enter", "loss", "purchasereturn", "retaildemand", "retailsalesreturn", + "inventory", "paymentout", "paymentin", "cashin", "cashout", + "retaildrawercashin", "retaildrawercashout"] + + +def _get(url, hdr): + for i in range(4): + try: + with urllib.request.urlopen(urllib.request.Request(url, headers=hdr), timeout=130, context=_ctx) as r: + raw = r.read() + import gzip + 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: + time.sleep(2); continue + if e.code >= 500: + time.sleep(2 * (i + 1)); continue + raise + except Exception: + time.sleep(2 * (i + 1)) + return None + + +def ms(path): + return _get(MS + path, {"Authorization": f"Bearer {MSTOK}", "Accept-Encoding": "gzip"}) + + +def am_token(): + data = urllib.parse.urlencode({"grant_type": "password", "username": USER, "password": PASS, + "client_id": "food-market-web", "scope": "openid profile email roles api"}).encode() + req = urllib.request.Request(AM + "/connect/token", data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) + with urllib.request.urlopen(req, timeout=60, context=_ctx) as r: + return json.loads(r.read())["access_token"] + + +def trigger(tok, doc_type, updated_from): + q = urllib.parse.urlencode({"type": doc_type, "updatedFrom": updated_from}) + req = urllib.request.Request(AM + "/api/admin/moysklad/import-supplies?" + q, data=b"{}", + headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=60, context=_ctx) as r: + return json.loads(r.read())["jobId"] + + +def poll(tok, job_id, timeout_s=1800): + t0 = time.time() + while time.time() - t0 < timeout_s: + d = _get(AM + f"/api/admin/moysklad/jobs/{job_id}", {"Authorization": f"Bearer {tok}"}) + if d and d.get("status") in ("Succeeded", "Failed"): + return d + time.sleep(5) + return {"status": "Timeout"} + + +def load_state(): + try: + return json.load(open(STATE)) + except Exception: + return {} + + +def save_state(st): + os.makedirs(os.path.dirname(STATE), exist_ok=True) + json.dump(st, open(STATE, "w"), ensure_ascii=False, indent=1) + + +def main(): + since = None + if "--since" in sys.argv: + since = sys.argv[sys.argv.index("--since") + 1] + st = load_state() + last = since or st.get("lastSync") + if not last: + print("Нет lastSync. Укажи --since 'YYYY-MM-DD HH:MM:SS' (МСК) при первом запуске.") + sys.exit(2) + # перекрытие 2 минуты на границе окна (идемпотентность защищает от дублей) + try: + last_dt = datetime.strptime(last, "%Y-%m-%d %H:%M:%S") - timedelta(minutes=2) + last = last_dt.strftime("%Y-%m-%d %H:%M:%S") + except Exception: + pass + run_start = datetime.now(MSK).strftime("%Y-%m-%d %H:%M:%S") + print(f"=== Дельта-синхронизация: изменения с {last} (МСК), старт {run_start} ===") + tok = am_token() + + total_created = 0 + for dt in DOC_TYPES: + jid = trigger(tok, dt, last) + res = poll(tok, jid) + created = res.get("Created", 0) or 0 + total_created += created + print(f" [{dt}] {res.get('status')}: создано {created}, пропущено {res.get('Skipped', 0)}. {res.get('Message','')[:120]}") + + # удаления + deleted = [] + audit = ms(f"/audit?limit=100&filter=eventType=delete;moment>={urllib.parse.quote(last)}") + if audit is None: + print(" [deletions] /audit недоступен (нет прав токена) — проверить удаления вручную на дне перехода.") + else: + for row in audit.get("rows", []): + if row.get("entityType") in AUDIT_ENTITIES: + deleted.append({"entityType": row.get("entityType"), "moment": row.get("moment"), "info": row.get("info")}) + print(f" [deletions] событий удаления документов с {last}: {len(deleted)}") + for d in deleted[:20]: + print(f" - {d['entityType']} @ {d['moment']}: {d['info']}") + + st["lastSync"] = run_start + st["lastRun"] = datetime.now(MSK).strftime("%Y-%m-%d %H:%M:%S") + st["lastCreated"] = total_created + st["lastDeletions"] = deleted + save_state(st) + print(f"=== Готово. Перенесено изменений: {total_created}. lastSync → {run_start}. Состояние: {STATE} ===") + + +if __name__ == "__main__": + main() diff --git a/scripts/migration/reconcile.py b/scripts/migration/reconcile.py index 9303810..b950455 100644 --- a/scripts/migration/reconcile.py +++ b/scripts/migration/reconcile.py @@ -164,3 +164,56 @@ try: print(f"| **Итого** | **{tot_md:,.2f}** | **{tot_od:,.2f}** | **{tot_od-tot_md:+,.2f}** | **{tot_mc}** | **{tot_oc}** | **{tot_oc-tot_mc:+d}** |") except Exception as e: print(f"_Секция выручки пропущена (ошибка): {e!r}_") + +# --------------------------------------------------------------------------- +# 4. Балансы счетов: МойСклад report/money/byaccount (наличные + банк) vs наши +# счета (OpeningBalance + Σ проводок). После «плуга» OpeningBalance должны совпасть. +# --------------------------------------------------------------------------- +print("\n## 4. Балансы счетов\n") +try: + mb=ms("/report/money/byaccount") + ms_cash=sum(r.get("balance",0) for r in (mb or {}).get("rows",[]) if "account" not in r)/100.0 + ms_bank=sum(r.get("balance",0) for r in (mb or {}).get("rows",[]) if "account" in r)/100.0 + # наши: In (1 PaymentIn,3 CashIn,6 TransferIn) +, Out (2,4,5) − + ours=sql("SELECT a.\\\"Kind\\\", a.\\\"OpeningBalance\\\" + COALESCE(SUM(CASE WHEN t.\\\"Kind\\\" IN (1,3,6) THEN t.\\\"Amount\\\" WHEN t.\\\"Kind\\\" IN (2,4,5) THEN -t.\\\"Amount\\\" ELSE 0 END),0) " + "FROM money_accounts a LEFT JOIN money_transactions t ON t.\\\"AccountId\\\"=a.\\\"Id\\\" AND t.\\\"Status\\\"=1 " + "WHERE a.\\\"Name\\\" LIKE '%из МойСклад%' GROUP BY a.\\\"Id\\\", a.\\\"Kind\\\", a.\\\"OpeningBalance\\\"") + our_cash=sum(float(r[1]) for r in ours if len(r)==2 and r[0]=='1') + our_bank=sum(float(r[1]) for r in ours if len(r)==2 and r[0]=='3') + print("| Счёт | МойСклад ₸ | ALL MARKET ₸ | Δ ₸ | Статус |") + print("|---|---|---|---|---|") + for nm,a,b in [("Наличные (касса)",ms_cash,our_cash),("Безнал (расчётный счёт)",ms_bank,our_bank)]: + d=b-a; print(f"| {nm} | {a:,.2f} | {b:,.2f} | {d:+,.2f} | {'✓' if abs(d)<0.5 else '⚠'} |") + da=(our_cash+our_bank)-(ms_cash+ms_bank) + print(f"| **Итого** | **{ms_cash+ms_bank:,.2f}** | **{our_cash+our_bank:,.2f}** | **{da:+,.2f}** | {'✓' if abs(da)<0.5 else '⚠'} |") + print("\n_Балансы сводятся «плугом» OpeningBalance = баланс МойСклада − Σ перенесённых проводок " + "(движения смен/розничная наличность до перехода не материализуются; плуг учитывает их как начальный остаток)._") +except Exception as e: + print(f"_Секция балансов пропущена (ошибка): {e!r}_") + +# --------------------------------------------------------------------------- +# 5. Долги контрагентов (взаиморасчёты). +# report/counterparty недоступен выданным read-only токеном (403). Поэтому +# показываем наш расчёт долгов (приёмки − оплачено − возвраты + корректировки) +# как ориентир; сверка с цифрой МойСклада требует права на отчёт взаиморасчётов. +# --------------------------------------------------------------------------- +print("\n## 5. Долги контрагентов (взаиморасчёты)\n") +chk=ms("/report/counterparty?limit=1") +if chk is None: + print("⚠ **Источник МойСклада `report/counterparty` недоступен выданным read-only токеном (403 — " + "нет права на отчёт взаиморасчётов).** Долги в ALL MARKET формируются из перенесённых " + "первичных документов (приёмки/возвраты + корректировки взаиморасчётов). Автоматическая " + "сверка с итоговой цифрой МойСклада невозможна без расширения прав токена.\n") + try: + rows=sql("SELECT COUNT(*), COALESCE(SUM(\\\"Total\\\"-\\\"PayedSum\\\"),0) FROM supplies WHERE \\\"Status\\\"=1") + if rows and len(rows[0])==2: + print(f"- Наш расчёт: поставщиков с открытыми приёмками, суммарный незакрытый долг по приёмкам: **{float(rows[0][1]):,.2f} ₸** ({rows[0][0]} приёмок проведено).") + adj=sql("SELECT COUNT(*) FROM counterparty_adjustments") + if adj: print(f"- Корректировок взаиморасчётов в системе: **{adj[0][0]}**.") + except Exception as e: + print(f"_Наш расчёт долгов пропущен: {e!r}_") + print("\n**Рекомендация на день перехода:** сверить итоговые долги вручную в UI МойСклада " + "(Контрагенты → взаиморасчёты) против отчёта ALL MARKET «Долги поставщикам», либо временно " + "выдать токену право на `report/counterparty` для авто-сверки.") +else: + print("_report/counterparty доступен — секция автосверки долгов будет добавлена._")