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
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:
parent
110ecf551d
commit
81398fadad
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue