#!/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}_")