feat(migration): дельта-драйвер + рунбук дня перехода + сверка балансов/долгов
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

- scripts/migration/delta_sync.py: раз в N мин updated>=lastSync по всем типам,
  удаления через /audit, состояние в ~/.fm-watchdog, перекрытие окна 2 мин
- docs/migration-runbook.md: фазы A/B/C, откат на МойСклад, надёжность, ограничения токена
- reconcile.py: секции балансов счетов (report/money/byaccount) и долгов (403-ограничение)
- progress: Parts 5/6/7 закрыты

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 16:36:14 +05:00
parent 39d0243ba3
commit 110ecf551d
4 changed files with 312 additions and 6 deletions

View file

@ -35,10 +35,19 @@
- [x] Списания (loss, −остаток), возвраты поставщику (purchasereturn, −остаток) — - [x] Списания (loss, −остаток), возвраты поставщику (purchasereturn, −остаток) —
мигратор написан по шаблону (запуск ПОСЛЕ retaildemand — параллельно нельзя, гонка мигратор написан по шаблону (запуск ПОСЛЕ retaildemand — параллельно нельзя, гонка
остатков по одной орг). остатков по одной орг).
- [ ] retailsalesreturn (связь с чеком по externalId), inventory, retailshift, - [x] **retailsalesreturn** (возврат розницы, связь с чеком по externalId demand, +остаток),
внесения/выплаты/ордера/платежи — по шаблону (следующая итерация). **inventory** (Completed без движений, BookQty/ActualQty из calculated/quantity) — в сборке.
- Эндпоинт `POST /import-supplies?type=enter|loss|purchasereturn|retaildemand` (фон). - [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). ВАЖНО: запускать типы ПОСЛЕДОВАТЕЛЬНО (не параллельно — одна орг, гонка stocks).
- [x] **Дельта** (`?updatedFrom=<МСК>`): все докимпортёры + денежные принимают `updatedFromMsk`
→ `filter=updated>=`. Драйвер `scripts/migration/delta_sync.py` (cron раз в N мин, poll
job'ов, удаления через `/audit`, состояние в `~/.fm-watchdog/migration-delta-state.json`).
- [ ] uom/склад/точка/сотрудники как отдельные справочники с externalId (пока товары на - [ ] uom/склад/точка/сотрудники как отдельные справочники с externalId (пока товары на
дефолтных ед.; склад — единственный склад орг). дефолтных ед.; склад — единственный склад орг).
3. [x] **Новая орг на стенде** «FOOD MARKET» (простой режим), admin `foodmarket-migr@food-market.local`. 3. [x] **Новая орг на стенде** «FOOD MARKET» (простой режим), admin `foodmarket-migr@food-market.local`.
@ -49,9 +58,12 @@
розничные продажи — перенос идёт (17 400 из ~97 731). Важно: счётчики МойСклада растут розничные продажи — перенос идёт (17 400 из ~97 731). Важно: счётчики МойСклада растут
(7125→7131, 97707→97731) — аккаунт ЖИВОЙ (магазин работает) → нужна дельта (Part 5). (7125→7131, 97707→97731) — аккаунт ЖИВОЙ (магазин работает) → нужна дельта (Part 5).
Остатки/долги/выручка-по-месяцам — секции после завершения продаж. Остатки/долги/выручка-по-месяцам — секции после завершения продаж.
5. [ ] Дельта-досинхронизация (updated>= МСК + удалённые). 5. [x] **Дельта-досинхронизация** (`updated>=` МСК + удалённые через `/audit`) —
6. [ ] Производительность/надёжность (резюмируемый курсор документов). `scripts/migration/delta_sync.py`; проверка «изменения появляются» — после деплоя.
7. [ ] Runbook «день перехода» + откат. 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 (справочники) — пайплайн проверен Part 1 (инвентаризация) и Part 3 (орг) — выполнены. Part 2 (справочники) — пайплайн проверен

90
docs/migration-runbook.md Normal file
View file

@ -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=<T>[&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 (сверка это учитывает).

View file

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

View file

@ -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}** |") 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: except Exception as e:
print(f"_Секция выручки пропущена (ошибка): {e!r}_") 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 доступен — секция автосверки долгов будет добавлена._")