feat(migration): дельта-драйвер + рунбук дня перехода + сверка балансов/долгов
- 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:
parent
39d0243ba3
commit
110ecf551d
|
|
@ -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 (справочники) — пайплайн проверен
|
||||
|
|
|
|||
90
docs/migration-runbook.md
Normal file
90
docs/migration-runbook.md
Normal 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 (сверка это учитывает).
|
||||
151
scripts/migration/delta_sync.py
Normal file
151
scripts/migration/delta_sync.py
Normal 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()
|
||||
|
|
@ -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 доступен — секция автосверки долгов будет добавлена._")
|
||||
|
|
|
|||
Loading…
Reference in a new issue