feat(migration): сверка остатков (report/stock/all vs stocks) + выручка/чеки по месяцам
- остатки по товару через Product.MoySkladExternalId, read-only SQL stage - выручка из report/sales/plotseries (÷100, МСК-месяцы), чеки из meta.size по filter moment - наши — retail_sales без возвратов, Date+3ч → МСК-месяц Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5d11dd7434
commit
ec2a9db8e3
|
|
@ -2,8 +2,22 @@
|
||||||
"""Сверка переноса МойСклад → ALL MARKET (только чтение обоих). Сравнивает:
|
"""Сверка переноса МойСклад → ALL MARKET (только чтение обоих). Сравнивает:
|
||||||
- число документов каждого типа; - остатки товар×склад (report/stock/all); - выручку и
|
- число документов каждого типа; - остатки товар×склад (report/stock/all); - выручку и
|
||||||
число чеков по месяцам. Пишет docs/migration-reconciliation.md. Расхождения помечает."""
|
число чеков по месяцам. Пишет 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 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"
|
MS="https://api.moysklad.ru/api/remap/1.2"
|
||||||
AM="https://test.admin.food-market.kz"
|
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]
|
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} |")
|
print(f"| {label} | {a} | {b} | {d} | {ok} |")
|
||||||
time.sleep(0.15)
|
time.sleep(0.15)
|
||||||
print(f"\n_Доккаунты: {'все сходятся' if allzero else 'есть расхождения/перенос продолжается (см. Δ<0 — ещё не все)'}._")
|
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}_")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue