feat(migration): сверка остатков (report/stock/all vs stocks) + выручка/чеки по месяцам

- остатки по товару через Product.MoySkladExternalId, read-only SQL stage
- выручка из report/sales/plotseries (÷100, МСК-месяцы), чеки из meta.size по filter moment
- наши — retail_sales без возвратов, Date+3ч → МСК-месяц

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 16:22:06 +05:00
parent 5d11dd7434
commit ec2a9db8e3

View file

@ -2,8 +2,22 @@
"""Сверка переноса МойСклад → 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 import os, json, gzip, time, urllib.request, urllib.error, sys, ssl, subprocess
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta
SSH="nns@192.168.1.190"
PSQL="docker exec food-market-stage-postgres psql -U food_market -d food_market -A -F'\t' -t -c"
def sql(q):
"""Только чтение stage БД через ssh (SELECT). Возвращает список кортежей строк."""
cmd=["ssh",SSH,f"{PSQL} \"{q}\""]
out=subprocess.run(cmd,capture_output=True,text=True,timeout=180)
rows=[]
for ln in out.stdout.splitlines():
ln=ln.strip("\r")
if ln=="": continue
rows.append(ln.split("\t"))
return rows
MS="https://api.moysklad.ru/api/remap/1.2" MS="https://api.moysklad.ru/api/remap/1.2"
AM="https://test.admin.food-market.kz" AM="https://test.admin.food-market.kz"
MSTOK=[l.split("=",1)[1].strip() for l in open(os.path.expanduser("~/.food-market-secrets/moysklad.env")) if l.startswith("MOYSKLAD_TOKEN=")][0] MSTOK=[l.split("=",1)[1].strip() for l in open(os.path.expanduser("~/.food-market-secrets/moysklad.env")) if l.startswith("MOYSKLAD_TOKEN=")][0]
@ -52,3 +66,101 @@ for label,ent,ap in DOCS:
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 — ещё не все)'}._") print(f"\n_Доккаунты: {'все сходятся' if allzero else 'есть расхождения/перенос продолжается (см. Δ<0 — ещё не все)'}._")
# ---------------------------------------------------------------------------
# 2. Остатки товар×склад: МойСклад report/stock/all (НЕ отдаёт нули) vs наши stocks.
# Маппинг по Product.MoySkladExternalId. МойСклад агрегирует по товару (один склад орг).
# ---------------------------------------------------------------------------
print("\n## 2. Остатки (товар)\n")
def ms_stock_by_product():
"""Суммарный остаток по товару (MS id -> qty). report/stock/all постранично."""
res=defaultdict(float); off=0
while True:
d=ms(f"/report/stock/all?limit=1000&offset={off}")
if not d: break
rows=d.get("rows",[])
for r in rows:
href=(r.get("meta") or {}).get("href","")
pid=href.rsplit("/",1)[-1].split("?")[0]
# variant/bundle -> берём как есть по его id; товары — основной случай
res[pid]+=float(r.get("stock",0) or 0)
off+=len(rows)
if len(rows)<1000 or off>=(d.get("meta",{}).get("size") or 0): break
time.sleep(0.2)
return res
def our_stock_by_msid():
rows=sql("SELECT p.\\\"MoySkladExternalId\\\", COALESCE(SUM(s.\\\"Quantity\\\"),0) "
"FROM products p LEFT JOIN stocks s ON s.\\\"ProductId\\\"=p.\\\"Id\\\" "
"WHERE p.\\\"MoySkladExternalId\\\" IS NOT NULL GROUP BY 1")
return {r[0]: float(r[1]) for r in rows if len(r)==2}
try:
msq=ms_stock_by_product(); ourq=our_stock_by_msid()
allids=set(msq)|set(ourq)
EPS=1e-6; mism=[]; matched=0
for pid in allids:
a=msq.get(pid,0.0); b=ourq.get(pid,0.0)
if abs(a-b)>EPS: mism.append((pid,a,b))
else: matched+=1
print(f"- Товаров с остатком в МойСкладе: **{sum(1 for v in msq.values() if abs(v)>EPS)}**")
print(f"- Позиций (товар) сопоставлено по остатку: **{matched}**")
print(f"- Расхождений: **{len(mism)}**")
if mism:
print("\n| MS товар id | МойСклад | ALL MARKET | Δ |")
print("|---|---|---|---|")
for pid,a,b in sorted(mism,key=lambda x:abs(x[1]-x[2]),reverse=True)[:50]:
print(f"| {pid} | {a:g} | {b:g} | {b-a:+g} |")
if len(mism)>50: print(f"\n_…ещё {len(mism)-50} расхождений (усечено)._")
else:
print("\n**Остатки сходятся полностью ✓**")
except Exception as e:
print(f"_Секция остатков пропущена (ошибка): {e!r}_")
# ---------------------------------------------------------------------------
# 3. Выручка и число чеков по месяцам.
# МойСклад: report/sales/plotseries (sum ÷100, по МЕСЯЦАМ, МСК) + meta.size по filter.
# Наши: retail_sales (NOT IsReturn), Date+3ч -> МСК-месяц.
# ---------------------------------------------------------------------------
print("\n## 3. Выручка и чеки по месяцам (розница, без возвратов)\n")
try:
# диапазон месяцев из наших данных (по МСК)
rng=sql("SELECT to_char(MIN(\\\"Date\\\"+interval '3 hour'),'YYYY-MM'), "
"to_char(MAX(\\\"Date\\\"+interval '3 hour'),'YYYY-MM') "
"FROM retail_sales WHERE \\\"MoySkladExternalId\\\" IS NOT NULL AND NOT \\\"IsReturn\\\"")
mmin,mmax=(rng[0][0],rng[0][1]) if rng and len(rng[0])==2 else (None,None)
# наши по месяцам
ours=sql("SELECT to_char(\\\"Date\\\"+interval '3 hour','YYYY-MM') m, COUNT(*), "
"COALESCE(SUM(\\\"Total\\\"),0) FROM retail_sales "
"WHERE \\\"MoySkladExternalId\\\" IS NOT NULL AND NOT \\\"IsReturn\\\" GROUP BY 1 ORDER BY 1")
ourm={r[0]:(int(r[1]),float(r[2])) for r in ours if len(r)==3}
# перечень месяцев
def monthiter(a,b):
y,m=int(a[:4]),int(a[5:7]); ye,me=int(b[:4]),int(b[5:7])
while (y,m)<=(ye,me):
yield f"{y:04d}-{m:02d}"
m+=1
if m>12: m=1; y+=1
months=list(monthiter(mmin,mmax)) if mmin else sorted(ourm)
print("| Месяц (МСК) | МС выручка ₸ | Наша выручка ₸ | Δ ₸ | МС чеков | Наших | Δ |")
print("|---|---|---|---|---|---|---|")
tot_md=tot_od=tot_mc=tot_oc=0
for mo in months:
y,m=int(mo[:4]),int(mo[5:7])
nm=f"{y+1:04d}-01" if m==12 else f"{y:04d}-{m+1:02d}"
frm=f"{mo}-01 00:00:00"; to=f"{nm}-01 00:00:00"
ps=ms(f"/report/sales/plotseries?momentFrom={frm.replace(' ','%20')}&momentTo={to.replace(' ','%20')}&interval=month")
ms_sum=0.0
if ps:
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}")
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
tot_md+=ms_rev; tot_od+=o_rev; tot_mc+=ms_cnt; tot_oc+=o_cnt
flag="" if abs(dd)<0.5 and dc==0 else " ⚠"
print(f"| {mo} | {ms_rev:,.2f} | {o_rev:,.2f} | {dd:+,.2f} | {ms_cnt} | {o_cnt} | {dc:+d}{flag} |")
time.sleep(0.2)
print(f"| **Итого** | **{tot_md:,.2f}** | **{tot_od:,.2f}** | **{tot_od-tot_md:+,.2f}** | **{tot_mc}** | **{tot_oc}** | **{tot_oc-tot_mc:+d}** |")
except Exception as e:
print(f"_Секция выручки пропущена (ошибка): {e!r}_")