food-market/scripts/migration/reconcile.py
nns ec2a9db8e3 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>
2026-10-09 16:22:06 +05:00

167 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Сверка переноса МойСклад → ALL MARKET (только чтение обоих). Сравнивает:
- число документов каждого типа; - остатки товар×склад (report/stock/all); - выручку и
число чеков по месяцам. Пишет docs/migration-reconciliation.md. Расхождения помечает."""
import os, json, gzip, time, urllib.request, urllib.error, sys, ssl, subprocess
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"
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]
_ctx=ssl.create_default_context(); _ctx.check_hostname=False; _ctx.verify_mode=ssl.CERT_NONE
def _get(url,hdr):
for i in range(4):
try:
req=urllib.request.Request(url,headers=hdr)
with urllib.request.urlopen(req,timeout=130,context=_ctx) as r:
raw=r.read()
if r.headers.get("Content-Encoding")=="gzip": raw=gzip.decompress(raw)
return json.loads(raw.decode("utf-8"))
except urllib.error.HTTPError as e:
if e.code==429: time.sleep(float(e.headers.get("X-RateLimit-Retry-TimeInterval",2000))/1000+0.5); continue
if e.code>=500: time.sleep(2*(i+1)); continue
raise
except Exception: time.sleep(2*(i+1))
return None
def ms(path): return _get(MS+path,{"Authorization":f"Bearer {MSTOK}","Accept-Encoding":"gzip"})
def am_token():
import urllib.parse
data=urllib.parse.urlencode({"grant_type":"password","username":"foodmarket-migr@food-market.local","password":"Migrate12345!","client_id":"food-market-web","scope":"openid profile email roles api"}).encode()
req=urllib.request.Request(AM+"/connect/token",data=data,headers={"Content-Type":"application/x-www-form-urlencoded"})
with urllib.request.urlopen(req,timeout=60,context=_ctx) as r: return json.loads(r.read())["access_token"]
TOK=am_token()
def am(path): return _get(AM+path,{"Authorization":f"Bearer {TOK}","Accept-Encoding":"gzip"})
def ms_size(ent): d=ms(f"/entity/{ent}?limit=1"); return d.get("meta",{}).get("size") if d else None
def am_total(path): d=am(path+("&" if "?" in path else "?")+"take=1&pageSize=1"); return d.get("total") if d else None
print("# Сверка переноса МойСклад → ALL MARKET\n")
print(f"_Сгенерировано {time.strftime('%Y-%m-%d %H:%M:%S')}._\n")
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")]
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 "✗")
if d!=0: allzero=False
print(f"| {label} | {a} | {b} | {d} | {ok} |")
time.sleep(0.15)
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}_")