diff --git a/scripts/migration/reconcile.py b/scripts/migration/reconcile.py index 0384a45..9303810 100644 --- a/scripts/migration/reconcile.py +++ b/scripts/migration/reconcile.py @@ -2,8 +2,22 @@ """Сверка переноса МойСклад → ALL MARKET (только чтение обоих). Сравнивает: - число документов каждого типа; - остатки товар×склад (report/stock/all); - выручку и число чеков по месяцам. Пишет 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 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] @@ -52,3 +66,101 @@ for label,ent,ap in DOCS: 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}_")