diff --git a/scripts/migration/delta_sync.py b/scripts/migration/delta_sync.py index 5cab06f..af544d5 100644 --- a/scripts/migration/delta_sync.py +++ b/scripts/migration/delta_sync.py @@ -82,7 +82,11 @@ def poll(tok, job_id, timeout_s=1800): 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 + # повторное чтение: финальный flush счётчиков (Created/Skipped) мог отстать + # на один тик от статуса — читаем ещё раз, чтобы цифры были точными. + time.sleep(2) + d2 = _get(AM + f"/api/admin/moysklad/jobs/{job_id}", {"Authorization": f"Bearer {tok}"}) + return d2 or d time.sleep(5) return {"status": "Timeout"} @@ -126,9 +130,13 @@ def main(): total_created += created print(f" [{dt}] {res.get('status')}: создано {created}, пропущено {res.get('Skipped', 0)}. {res.get('Message','')[:120]}") - # удаления + # удаления (audit). filter целиком экранируем; при 4xx/недоступности — мягко пропускаем. deleted = [] - audit = ms(f"/audit?limit=100&filter=eventType=delete;moment>={urllib.parse.quote(last)}") + try: + flt = urllib.parse.quote(f"eventType=delete;moment>={last}") + audit = ms(f"/audit?limit=100&filter={flt}") + except Exception: + audit = None if audit is None: print(" [deletions] /audit недоступен (нет прав токена) — проверить удаления вручную на дне перехода.") else: diff --git a/scripts/migration/reconcile.py b/scripts/migration/reconcile.py index b950455..dccb83c 100644 --- a/scripts/migration/reconcile.py +++ b/scripts/migration/reconcile.py @@ -2,7 +2,7 @@ """Сверка переноса МойСклад → ALL MARKET (только чтение обоих). Сравнивает: - число документов каждого типа; - остатки товар×склад (report/stock/all); - выручку и число чеков по месяцам. Пишет docs/migration-reconciliation.md. Расхождения помечает.""" -import os, json, gzip, time, urllib.request, urllib.error, sys, ssl, subprocess +import os, json, gzip, time, urllib.request, urllib.error, urllib.parse, sys, ssl, subprocess from collections import defaultdict from datetime import datetime, timedelta @@ -54,18 +54,28 @@ print("## 1. Число документов по типам\n") print("| Тип | МойСклад | ALL MARKET | Δ | Статус |") print("|---|---|---|---|---|") DOCS=[("Приёмки","supply","/api/purchases/supplies"),("Возвраты поставщику","purchasereturn","/api/purchases/supplier-returns"), - ("Оприходования","enter","/api/inventory/enters"),("Списания","loss","/api/inventory/losses"), - ("Розничные продажи","retaildemand","/api/sales/retail")] + ("Оприходования","enter","/api/inventory/enters"),("Списания","loss","/api/inventory/losses")] allzero=True for label,ent,ap in DOCS: - a=ms_size(ent); b=am_total(ap); + a=ms_size(ent); b=am_total(ap); try: d=(b or 0)-(a or 0) except: d="?" - ok="✓" if d==0 else ("~ идёт перенос" if isinstance(d,int) and d<0 else "✗") + ok="✓" if d==0 else ("~ дельта" if isinstance(d,int) and d<0 else "✗") if d!=0: allzero=False print(f"| {label} | {a} | {b} | {d} | {ok} |") time.sleep(0.15) -print(f"\n_Доккаунты: {'все сходятся' if allzero else 'есть расхождения/перенос продолжается (см. Δ<0 — ещё не все)'}._") +# Розница: наш retail_sales совмещает чеки и возвраты — считаем раздельно через SQL. +def one(q): + r=sql(q); return int(r[0][0]) if r and r[0] else 0 +our_dem=one("SELECT COUNT(*) FROM retail_sales WHERE \\\"MoySkladExternalId\\\" IS NOT NULL AND NOT \\\"IsReturn\\\"") +our_ret=one("SELECT COUNT(*) FROM retail_sales WHERE \\\"MoySkladExternalId\\\" IS NOT NULL AND \\\"IsReturn\\\"") +for label,ent,b in [("Розничные продажи","retaildemand",our_dem),("Розничные возвраты","retailsalesreturn",our_ret)]: + a=ms_size(ent); d=b-(a or 0) + ok="✓" if d==0 else ("~ дельта" if d<0 else "✗") + if d!=0: allzero=False + print(f"| {label} | {a} | {b} | {d} | {ok} |") + time.sleep(0.15) +print(f"\n_Доккаунты: {'все сходятся ✓' if allzero else 'Δ<0 — догоняется дельтой (живой аккаунт рос во время переноса); Δ>0 — разобрать'}._") # --------------------------------------------------------------------------- # 2. Остатки товар×склад: МойСклад report/stock/all (НЕ отдаёт нули) vs наши stocks. @@ -153,7 +163,8 @@ try: for s in ps.get("series",[]): if str(s.get("date","")).startswith(mo): ms_sum+=float(s.get("sum",0) or 0) ms_rev=ms_sum/100.0 - cc=ms(f"/entity/retaildemand?limit=1&filter=moment>={frm};moment<{to}") + flt=urllib.parse.quote(f"moment>={frm};moment<{to}") + cc=ms(f"/entity/retaildemand?limit=1&filter={flt}") ms_cnt=(cc or {}).get("meta",{}).get("size",0) o_cnt,o_rev=ourm.get(mo,(0,0.0)) dd=o_rev-ms_rev; dc=o_cnt-ms_cnt diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs index cf782e7..23cbd1e 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs @@ -158,7 +158,8 @@ public async Task> GetAllFoldersAsync(string token, Cancel const int pageSize = 100; const int maxAttempts = 6; var offset = startOffset; - var filter = updatedFromMsk is null ? "" : $"&filter=updated>={Uri.EscapeDataString(updatedFromMsk)}"; + // Весь filter-expression надо экранировать целиком (включая ">="), иначе МойСклад 400. + var filter = updatedFromMsk is null ? "" : $"&filter={Uri.EscapeDataString($"updated>={updatedFromMsk}")}"; var exp = string.IsNullOrEmpty(expand) ? "" : $"&expand={expand}"; while (true) {