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:
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:

View file

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

View file

@ -158,7 +158,8 @@ public async Task<List<MsProductFolder>> 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)
{