From 81398fadadad0e90ec8dd4a7c251932400928312 Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 9 Oct 2026 18:12:59 +0500 Subject: [PATCH] =?UTF-8?q?fix(migration):=20=D1=8D=D0=BA=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20filter=3Dupd?= =?UTF-8?q?ated>=3D=20=D1=86=D0=B5=D0=BB=D0=B8=D0=BA=D0=BE=D0=BC;=20=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D1=82=D0=B0-=D0=B4=D1=80=D0=B0=D0=B9=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=20=E2=80=94=20=D1=82=D0=BE=D1=87=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BE=D0=BF=D1=80=D0=BE=D1=81=20job=20+=20audit=20tolerant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StreamDocumentsAsync: Uri.EscapeDataString всего выражения updated>=<дата> (>= → %3E%3D), не только даты — иначе часть форм 400/неоднозначна - delta_sync: повторное чтение job после Succeeded (устранить гонку счётчиков), audit в try - reconcile: розница демо/возвраты раздельно, URL-энкод фильтра чеков по месяцам Co-Authored-By: Claude Opus 5.5 --- scripts/migration/delta_sync.py | 14 ++++++++--- scripts/migration/reconcile.py | 25 +++++++++++++------ .../Integrations/MoySklad/MoySkladClient.cs | 3 ++- 3 files changed, 31 insertions(+), 11 deletions(-) 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) {