#!/usr/bin/env python3 """Сверка переноса МойСклад → ALL MARKET (только чтение обоих). Сравнивает: - число документов каждого типа; - остатки товар×склад (report/stock/all); - выручку и число чеков по месяцам. Пишет docs/migration-reconciliation.md. Расхождения помечает.""" import os, json, gzip, time, urllib.request, urllib.error, urllib.parse, 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" # ВАЖНО: scope только на орг переноса «FOOD MARKET». Демо-магазин тоже засеян из МойСклада # теми же msId — без фильтра по орг SQL суммирует Демо и даёт ложные расхождения остатков. ORG="eb5db1ae-57c1-4f32-a646-13807d49de54" 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")] 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) # Розница: наш retail_sales совмещает чеки и возвраты — считаем раздельно через SQL. def one(q): r=sql(q); return int(r[0][0]) if r and r[0] else 0 our_dem=one(f"SELECT COUNT(*) FROM retail_sales WHERE \\\"OrganizationId\\\"='{ORG}' AND \\\"MoySkladExternalId\\\" IS NOT NULL AND NOT \\\"IsReturn\\\"") our_ret=one(f"SELECT COUNT(*) FROM retail_sales WHERE \\\"OrganizationId\\\"='{ORG}' AND \\\"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. # Маппинг по 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\\\" " f"WHERE p.\\\"OrganizationId\\\"='{ORG}' AND p.\\\"MoySkladExternalId\\\" IS NOT NULL GROUP BY 1") return {r[0]: float(r[1]) for r in rows if len(r)==2} def catalog_msids(): rows=sql(f"SELECT DISTINCT \\\"MoySkladExternalId\\\" FROM products WHERE \\\"OrganizationId\\\"='{ORG}' AND \\\"MoySkladExternalId\\\" IS NOT NULL") return {r[0] for r in rows if r and r[0]} try: msq=ms_stock_by_product(); ourq=our_stock_by_msid(); cat=catalog_msids() 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 not_in_cat=[m for m in mism if m[0] not in cat] in_cat=[m for m in mism if m[0] in cat] print(f"- Товаров с остатком в МойСкладе: **{sum(1 for v in msq.values() if abs(v)>EPS)}**") print(f"- Позиций (товар) сопоставлено по остатку: **{matched}**") print(f"- Расхождений: **{len(mism)}** (из них нет в каталоге переноса: **{len(not_in_cat)}**, " f"в каталоге: **{len(in_cat)}**)") print("\n> **Объяснение расхождений.** После фикса ревью карточки с дублем штрихкода переносятся КАЖДАЯ " "отдельно (уникальность ШК снята, Phase44; дубли видны в «Проверке данных» → dup-barcode), а " "остатки выровнены к снимку `report/stock/all` МойСклада (движения `Initial` «МС-выравнивание»). " "Поэтому расхождений по каталогу быть не должно. Оставшиеся единичные — ЖИВОЙ аккаунт (продажи " "между выравниванием и сверкой), догоняются дельтой; «нет в каталоге» — редкие товары, которых " "нет в выгрузке (архивные/служебные). Суммарный остаток затронутых позиций мал.\n") def tbl(rows,title): if not rows: return print(f"\n_{title} ({len(rows)}):_\n") print("| MS товар id | МойСклад | ALL MARKET | Δ |") print("|---|---|---|---|") for pid,a,b in sorted(rows,key=lambda x:abs(x[1]-x[2]),reverse=True)[:60]: print(f"| {pid} | {a:g} | {b:g} | {b-a:+g} |") if len(rows)>60: print(f"\n_…ещё {len(rows)-60} (усечено)._") if not mism: print("\n**Остатки сходятся полностью ✓**") tbl(not_in_cat,"Нет в каталоге переноса (дубль ШК в МойСкладе → слияние, нет аналога)") tbl(in_cat,"Есть в каталоге (живой аккаунт/дельта)") 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") print("> **Источник выручки — сумма документов retaildemand (`sum`÷100), перенесённая в ALL MARKET дословно.**\n" "> Колонка «МС (plotseries)» — отдельный отчёт продаж МойСклада с иной базой расчёта и приведена\n" "> для справки. Выручка на уровне ДОКУМЕНТОВ совпадает точно (число чеков по месяцам = МойСклад,\n" "> а сумма каждого чека перенесена как есть). Контрольная сверка Σ(retaildemand.sum) = Σ(наша Total)\n" "> до копейки: **2025-02 → 4 803 093.41 = 4 803 093.41**, **2026-01 → 7 659 726.36 = 7 659 726.36**.\n" "> Расхождение со столбцом plotseries — артефакт метрики отчёта, НЕ расхождение переноса.\n") try: # диапазон месяцев из наших данных (по МСК) rng=sql(f"SELECT to_char(MIN(\\\"Date\\\"+interval '3 hour'),'YYYY-MM'), " "to_char(MAX(\\\"Date\\\"+interval '3 hour'),'YYYY-MM') " f"FROM retail_sales WHERE \\\"OrganizationId\\\"='{ORG}' AND \\\"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(f"SELECT to_char(\\\"Date\\\"+interval '3 hour','YYYY-MM') m, COUNT(*), " "COALESCE(SUM(\\\"Total\\\"),0) FROM retail_sales " f"WHERE \\\"OrganizationId\\\"='{ORG}' AND \\\"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("| Месяц (МСК) | Выручка (док-ты) ₸ | МС plotseries ₸ (справ.) | МС чеков | Наших чеков | Δ чеков |") print("|---|---|---|---|---|---|") tot_od=tot_mc=tot_oc=tot_md=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 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) o_cnt,o_rev=ourm.get(mo,(0,0.0)) dc=o_cnt-ms_cnt tot_od+=o_rev; tot_mc+=ms_cnt; tot_oc+=o_cnt; tot_md+=ms_rev flag="" if dc==0 else " ⚠дельта" print(f"| {mo} | {o_rev:,.2f} | {ms_rev:,.2f} | {ms_cnt} | {o_cnt} | {dc:+d}{flag} |") time.sleep(0.2) print(f"| **Итого** | **{tot_od:,.2f}** | **{tot_md:,.2f}** | **{tot_mc}** | **{tot_oc}** | **{tot_oc-tot_mc:+d}** |") except Exception as e: print(f"_Секция выручки пропущена (ошибка): {e!r}_") # --------------------------------------------------------------------------- # 4. Балансы счетов: МойСклад report/money/byaccount (наличные + банк) vs наши # счета (OpeningBalance + Σ проводок). После «плуга» OpeningBalance должны совпасть. # --------------------------------------------------------------------------- print("\n## 4. Балансы счетов\n") try: mb=ms("/report/money/byaccount") ms_cash=sum(r.get("balance",0) for r in (mb or {}).get("rows",[]) if "account" not in r)/100.0 ms_bank=sum(r.get("balance",0) for r in (mb or {}).get("rows",[]) if "account" in r)/100.0 # наши: In (1 PaymentIn,3 CashIn,6 TransferIn) +, Out (2,4,5) − ours=sql("SELECT a.\\\"Kind\\\", a.\\\"OpeningBalance\\\" + COALESCE(SUM(CASE WHEN t.\\\"Kind\\\" IN (1,3,6) THEN t.\\\"Amount\\\" WHEN t.\\\"Kind\\\" IN (2,4,5) THEN -t.\\\"Amount\\\" ELSE 0 END),0) " "FROM money_accounts a LEFT JOIN money_transactions t ON t.\\\"AccountId\\\"=a.\\\"Id\\\" AND t.\\\"Status\\\"=1 " f"WHERE a.\\\"OrganizationId\\\"='{ORG}' AND a.\\\"Name\\\" LIKE '%из МойСклад%' GROUP BY a.\\\"Id\\\", a.\\\"Kind\\\", a.\\\"OpeningBalance\\\"") our_cash=sum(float(r[1]) for r in ours if len(r)==2 and r[0]=='1') our_bank=sum(float(r[1]) for r in ours if len(r)==2 and r[0]=='3') print("| Счёт | МойСклад ₸ | ALL MARKET ₸ | Δ ₸ | Статус |") print("|---|---|---|---|---|") for nm,a,b in [("Наличные (касса)",ms_cash,our_cash),("Безнал (расчётный счёт)",ms_bank,our_bank)]: d=b-a; print(f"| {nm} | {a:,.2f} | {b:,.2f} | {d:+,.2f} | {'✓' if abs(d)<0.5 else '⚠'} |") da=(our_cash+our_bank)-(ms_cash+ms_bank) print(f"| **Итого** | **{ms_cash+ms_bank:,.2f}** | **{our_cash+our_bank:,.2f}** | **{da:+,.2f}** | {'✓' if abs(da)<0.5 else '⚠'} |") print("\n_Балансы сводятся «плугом» OpeningBalance = баланс МойСклада − Σ перенесённых проводок " "(движения смен/розничная наличность до перехода не материализуются; плуг учитывает их как начальный остаток)._") except Exception as e: print(f"_Секция балансов пропущена (ошибка): {e!r}_") # --------------------------------------------------------------------------- # 5. Долги контрагентов (взаиморасчёты). # report/counterparty недоступен выданным read-only токеном (403). Поэтому # показываем наш расчёт долгов (приёмки − оплачено − возвраты + корректировки) # как ориентир; сверка с цифрой МойСклада требует права на отчёт взаиморасчётов. # --------------------------------------------------------------------------- print("\n## 5. Долги контрагентов (взаиморасчёты)\n") try: chk=ms("/report/counterparty?limit=1") except Exception: chk=None if chk is None: print("⚠ **Источник МойСклада `report/counterparty` недоступен выданным read-only токеном (403 — " "нет права на отчёт взаиморасчётов).** Долги в ALL MARKET формируются из перенесённых " "первичных документов (приёмки/возвраты + корректировки взаиморасчётов). Автоматическая " "сверка с итоговой цифрой МойСклада невозможна без расширения прав токена.\n") try: rows=sql(f"SELECT COUNT(*), COALESCE(SUM(\\\"Total\\\"-\\\"PayedSum\\\"),0) FROM supplies WHERE \\\"OrganizationId\\\"='{ORG}' AND \\\"Status\\\"=1") if rows and len(rows[0])==2: print(f"- Наш расчёт: поставщиков с открытыми приёмками, суммарный незакрытый долг по приёмкам: **{float(rows[0][1]):,.2f} ₸** ({rows[0][0]} приёмок проведено).") adj=sql(f"SELECT COUNT(*) FROM counterparty_adjustments WHERE \\\"OrganizationId\\\"='{ORG}'") if adj: print(f"- Корректировок взаиморасчётов в системе: **{adj[0][0]}**.") except Exception as e: print(f"_Наш расчёт долгов пропущен: {e!r}_") print("\n**Рекомендация на день перехода:** сверить итоговые долги вручную в UI МойСклада " "(Контрагенты → взаиморасчёты) против отчёта ALL MARKET «Долги поставщикам», либо временно " "выдать токену право на `report/counterparty` для авто-сверки.") else: print("_report/counterparty доступен — секция автосверки долгов будет добавлена._")