- reconcile: ORG-фильтр во всех SQL (остатки/выручка/балансы/долги) — без него Демо-магазин (те же msId из МС) суммировался и давал ложные расхождения остатков - орг «FOOD MARKET» чистая: 30944 товара = 30944 msId, stocks == Σдвижений - остатки: 42 расхождения классифицированы — 35 дубль-ШК в МС (слияние, нет аналога, объяснено + решение), 7 живой аккаунт (±1-4, дельта). Все объяснены построчно - delta подтверждён: подтянул 4 живые продажи Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
257 lines
19 KiB
Python
257 lines
19 KiB
Python
#!/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> **Объяснение расхождений.** Почти все — товары МойСклада с ДУБЛИРУЮЩИМ штрихкодом "
|
||
"(в МС несколько карточек на один ШК, часто весовые/хлеб с «0…»). В ALL MARKET штрихкод "
|
||
"уникален, поэтому такие карточки слиты в одну (`MergeByBarcode`), а msId «лишней» карточки "
|
||
"в каталог не попал → её небольшой остаток не сопоставляется (нет аналога в нашей модели). "
|
||
"Единичные расхождения по товарам, которые ЕСТЬ в каталоге — живой аккаунт (продажи во время "
|
||
"сверки, догоняются дельтой). **Решение:** для чистого перехода устранить дубли ШК в МойСкладе "
|
||
"(задать уникальные коды) ЛИБО принять слияние — суммарный остаток затронутых позиций мал.\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 доступен — секция автосверки долгов будет добавлена._")
|