fix(migration): экранировать filter=updated>= целиком; дельта-драйвер — точный повторный опрос job + audit tolerant
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
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions

- StreamDocumentsAsync: Uri.EscapeDataString всего выражения updated>=<дата> (>= → %3E%3D),
  не только даты — иначе часть форм 400/неоднозначна
- delta_sync: повторное чтение job после Succeeded (устранить гонку счётчиков), audit в try
- reconcile: розница демо/возвраты раздельно, URL-энкод фильтра чеков по месяцам

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 18:12:59 +05:00
parent 110ecf551d
commit 81398fadad
3 changed files with 31 additions and 11 deletions

View file

@ -82,7 +82,11 @@ def poll(tok, job_id, timeout_s=1800):
while time.time() - t0 < timeout_s: while time.time() - t0 < timeout_s:
d = _get(AM + f"/api/admin/moysklad/jobs/{job_id}", {"Authorization": f"Bearer {tok}"}) d = _get(AM + f"/api/admin/moysklad/jobs/{job_id}", {"Authorization": f"Bearer {tok}"})
if d and d.get("status") in ("Succeeded", "Failed"): 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) time.sleep(5)
return {"status": "Timeout"} return {"status": "Timeout"}
@ -126,9 +130,13 @@ def main():
total_created += created total_created += created
print(f" [{dt}] {res.get('status')}: создано {created}, пропущено {res.get('Skipped', 0)}. {res.get('Message','')[:120]}") print(f" [{dt}] {res.get('status')}: создано {created}, пропущено {res.get('Skipped', 0)}. {res.get('Message','')[:120]}")
# удаления # удаления (audit). filter целиком экранируем; при 4xx/недоступности — мягко пропускаем.
deleted = [] 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: if audit is None:
print(" [deletions] /audit недоступен (нет прав токена) — проверить удаления вручную на дне перехода.") print(" [deletions] /audit недоступен (нет прав токена) — проверить удаления вручную на дне перехода.")
else: else:

View file

@ -2,7 +2,7 @@
"""Сверка переноса МойСклад → ALL MARKET (только чтение обоих). Сравнивает: """Сверка переноса МойСклад → ALL MARKET (только чтение обоих). Сравнивает:
- число документов каждого типа; - остатки товар×склад (report/stock/all); - выручку и - число документов каждого типа; - остатки товар×склад (report/stock/all); - выручку и
число чеков по месяцам. Пишет docs/migration-reconciliation.md. Расхождения помечает.""" число чеков по месяцам. Пишет 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 collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -54,18 +54,28 @@ print("## 1. Число документов по типам\n")
print("| Тип | МойСклад | ALL MARKET | Δ | Статус |") print("| Тип | МойСклад | ALL MARKET | Δ | Статус |")
print("|---|---|---|---|---|") print("|---|---|---|---|---|")
DOCS=[("Приёмки","supply","/api/purchases/supplies"),("Возвраты поставщику","purchasereturn","/api/purchases/supplier-returns"), DOCS=[("Приёмки","supply","/api/purchases/supplies"),("Возвраты поставщику","purchasereturn","/api/purchases/supplier-returns"),
("Оприходования","enter","/api/inventory/enters"),("Списания","loss","/api/inventory/losses"), ("Оприходования","enter","/api/inventory/enters"),("Списания","loss","/api/inventory/losses")]
("Розничные продажи","retaildemand","/api/sales/retail")]
allzero=True allzero=True
for label,ent,ap in DOCS: 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) try: d=(b or 0)-(a or 0)
except: d="?" 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 if d!=0: allzero=False
print(f"| {label} | {a} | {b} | {d} | {ok} |") print(f"| {label} | {a} | {b} | {d} | {ok} |")
time.sleep(0.15) 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. # 2. Остатки товар×склад: МойСклад report/stock/all (НЕ отдаёт нули) vs наши stocks.
@ -153,7 +163,8 @@ try:
for s in ps.get("series",[]): for s in ps.get("series",[]):
if str(s.get("date","")).startswith(mo): ms_sum+=float(s.get("sum",0) or 0) if str(s.get("date","")).startswith(mo): ms_sum+=float(s.get("sum",0) or 0)
ms_rev=ms_sum/100.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) ms_cnt=(cc or {}).get("meta",{}).get("size",0)
o_cnt,o_rev=ourm.get(mo,(0,0.0)) o_cnt,o_rev=ourm.get(mo,(0,0.0))
dd=o_rev-ms_rev; dc=o_cnt-ms_cnt dd=o_rev-ms_rev; dc=o_cnt-ms_cnt

View file

@ -158,7 +158,8 @@ public async Task<List<MsProductFolder>> GetAllFoldersAsync(string token, Cancel
const int pageSize = 100; const int pageSize = 100;
const int maxAttempts = 6; const int maxAttempts = 6;
var offset = startOffset; 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}"; var exp = string.IsNullOrEmpty(expand) ? "" : $"&expand={expand}";
while (true) while (true)
{ {