food-market/scripts/migration/reconcile.py
nns b1bdf5c228
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
fix(migration): не схлопывать разные карточки МойСклада по артикулу + актуализировать объяснение сверки
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:11:06 +05:00

256 lines
19 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, 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 доступен — секция автосверки долгов будет добавлена._")