Compare commits

..

No commits in common. "39d0243ba3dba681d2c4ac9e91d571b43ba82711" and "5d11dd743426fc1078e1fc834d71814a248affc8" have entirely different histories.

5 changed files with 1 additions and 255 deletions

View file

@ -2,22 +2,8 @@
"""Сверка переноса МойСклад → ALL MARKET (только чтение обоих). Сравнивает:
- число документов каждого типа; - остатки товар×склад (report/stock/all); - выручку и
число чеков по месяцам. Пишет docs/migration-reconciliation.md. Расхождения помечает."""
import os, json, gzip, time, urllib.request, urllib.error, sys, ssl, subprocess
import os, json, gzip, time, urllib.request, urllib.error, sys, ssl
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]
@ -66,101 +52,3 @@ 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}_")

View file

@ -174,25 +174,6 @@ public async Task<ActionResult<object>> ImportSupplies([FromBody] ImportRequest
case "retaildemand": await docs.ImportRetailSalesAsync(token, startOffset, pr, CancellationToken.None, updFrom); break;
case "retailsalesreturn": await docs.ImportRetailReturnsAsync(token, startOffset, pr, CancellationToken.None, updFrom); break;
case "inventory": await docs.ImportInventoriesAsync(token, startOffset, pr, CancellationToken.None, updFrom); break;
// одиночный денежный тип (резюмируемо по offset)
case "paymentout" or "paymentin" or "cashin" or "cashout" or "retaildrawercashin" or "retaildrawercashout":
await docs.ImportMoneyDocsAsync(token, type, startOffset, pr, CancellationToken.None, updFrom); break;
// все денежные документы по очереди + плуг балансов счетов
case "money":
{
foreach (var ent in new[] { "paymentout", "paymentin", "cashin", "cashout", "retaildrawercashin", "retaildrawercashout" })
{
job.Stage = $"Деньги: {ent}…"; await _jobs.SaveAsync(job);
await docs.ImportMoneyDocsAsync(token, ent, 0, pr, CancellationToken.None, updFrom);
}
job.Stage = "Плуг балансов счетов…"; await _jobs.SaveAsync(job);
var bal = await docs.SetMoneyOpeningBalancesAsync(token, CancellationToken.None);
pr.ErrorSamples.Add("Балансы: " + bal);
break;
}
// только пересчёт OpeningBalance (после правок проводок)
case "balances":
pr.ErrorSamples.Add("Балансы: " + await docs.SetMoneyOpeningBalancesAsync(token, CancellationToken.None)); break;
default: await docs.ImportSuppliesAsync(token, startOffset, pr, CancellationToken.None, updFrom); break;
}
job.Total = pr.Seen; job.Created = pr.Created; job.Skipped = pr.Skipped;

View file

@ -55,17 +55,6 @@ private HttpRequestMessage Build(HttpMethod method, string pathAndQuery, string
return req;
}
/// <summary>report/money/byaccount — текущие остатки по деньгам (наличные + банк. счета),
/// для сверки балансов счетов при переносе. Только чтение. null при ошибке/запрете.</summary>
public async Task<List<MsMoneyByAccountRow>?> GetMoneyByAccountAsync(string token, CancellationToken ct)
{
using var req = Build(HttpMethod.Get, "report/money/byaccount", token);
using var res = await _http.SendAsync(req, ct);
if (!res.IsSuccessStatusCode) return null;
var list = await res.Content.ReadFromJsonAsync<MsListResponse<MsMoneyByAccountRow>>(Json, ct);
return list?.Rows;
}
public async Task<MoySkladApiResult<MsOrganization>> WhoAmIAsync(string token, CancellationToken ct)
{
using var req = Build(HttpMethod.Get, "entity/organization?limit=1", token);

View file

@ -2,7 +2,6 @@
using foodmarket.Application.Common.Tenancy;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Money;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@ -383,107 +382,6 @@ await foreach (var d in _client.StreamDocumentsAsync<MsDocument>(token, "entity/
return progress;
}
// ── Денежные документы (платежи/ордера/внесения/выплаты) ─────────────────
// paymentout→PaymentOut(банк), paymentin→PaymentIn(банк), cashin→CashIn(касса),
// cashout→CashOut(касса), retaildrawercashin→CashIn+DrawerIn, retaildrawercashout→
// CashOut+DrawerOut. Контрагент — по agent (externalId), иначе null (внутр. движения).
// Суммы из тиынов, даты МСК→UTC. Баланс счёта сходится плугом OpeningBalance
// (см. SetMoneyOpeningBalancesAsync) — проводки здесь это ИСТОРИЯ, не источник баланса.
public async Task<DocImportProgress> ImportMoneyDocsAsync(string token, string msEntity, int startOffset,
DocImportProgress progress, CancellationToken ct, string? updatedFromMsk = null)
{
var orgId = _tenant.OrganizationId!.Value; progress.Entity = msEntity;
var m = await LoadMapsAsync(orgId, ct);
var (cashId, bankId) = await EnsureMoneyAccountsAsync(orgId, m.CurrencyId, m.StoreId, ct);
var (kind, category, accountId) = MoneyMapping(msEntity, cashId, bankId);
var done = (await _db.MoneyTransactions.Where(x => x.MoySkladExternalId != null).Select(x => x.MoySkladExternalId!).ToListAsync(ct)).ToHashSet();
var used = (await _db.MoneyTransactions.Select(x => x.Number).ToListAsync(ct)).ToHashSet(StringComparer.OrdinalIgnoreCase);
string Uniq(string b, int y) { if (used.Add(b)) return b; var w = $"{b}-{y}"; if (used.Add(w)) return w; for (var n = 2; ; n++) { var c = $"{b}-{y}-{n}"; if (used.Add(c)) return c; } }
await foreach (var d in _client.StreamDocumentsAsync<MsDocument>(token, $"entity/{msEntity}", "agent", startOffset, updatedFromMsk, ct))
{
progress.Seen++; if (d.Id is null || done.Contains(d.Id)) { progress.Skipped++; continue; }
try
{
var mom = MomentToUtc(d.Moment);
var agentMs = IdFromHref(d.Agent?.Meta?.Href);
Guid? cpId = (agentMs != null && m.Counterparties.TryGetValue(agentMs, out var c)) ? c : null;
var mt = new MoneyTransaction {
OrganizationId = orgId, Number = Uniq(d.Name ?? $"МС-{d.Id[..8]}", mom.Year), Date = mom,
Kind = kind, Category = category, Status = MoneyTransactionStatus.Posted,
AccountId = accountId, CounterpartyId = cpId, CurrencyId = m.CurrencyId,
Amount = d.Sum / 100m, Purpose = Trunc(d.PaymentPurpose, 500), Notes = Trunc(d.Description, 1000),
PostedAt = mom, PostedByUserId = m.AdminUserId, MoySkladExternalId = d.Id };
_db.MoneyTransactions.Add(mt);
await _db.SaveChangesAsync(ct); _db.ChangeTracker.Clear(); done.Add(d.Id); progress.Created++;
}
catch (Exception ex) { _db.ChangeTracker.Clear(); progress.Errors++; if (progress.ErrorSamples.Count < 20) progress.ErrorSamples.Add($"{d.Name}: {ex.Message}"); _log.LogWarning(ex, "{Ent} {Id}", msEntity, d.Id); }
}
return progress;
}
private static string? Trunc(string? s, int max) => string.IsNullOrEmpty(s) ? null : (s.Length <= max ? s : s[..max]);
private static (MoneyTransactionKind, MoneyTransactionCategory, Guid) MoneyMapping(string ent, Guid cashId, Guid bankId) => ent switch
{
"paymentout" => (MoneyTransactionKind.PaymentOut, MoneyTransactionCategory.Manual, bankId),
"paymentin" => (MoneyTransactionKind.PaymentIn, MoneyTransactionCategory.Manual, bankId),
"cashin" => (MoneyTransactionKind.CashIn, MoneyTransactionCategory.Manual, cashId),
"cashout" => (MoneyTransactionKind.CashOut, MoneyTransactionCategory.Manual, cashId),
"retaildrawercashin" => (MoneyTransactionKind.CashIn, MoneyTransactionCategory.DrawerIn, cashId),
"retaildrawercashout" => (MoneyTransactionKind.CashOut, MoneyTransactionCategory.DrawerOut, cashId),
_ => throw new ArgumentException($"Неизвестный денежный тип: {ent}")
};
public const string CashAccountName = "Касса (из МойСклад)";
public const string BankAccountName = "Расчётный счёт (из МойСклад)";
/// <summary>Гарантирует кассу (Cash) и расчётный счёт (Bank) орг. Идемпотентно по имени.
/// IsDefault=false — чтобы не конфликтовать с уникальным индексом «один default на Kind».</summary>
private async Task<(Guid cashId, Guid bankId)> EnsureMoneyAccountsAsync(Guid orgId, Guid currencyId, Guid storeId, CancellationToken ct)
{
var open = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var cash = await _db.MoneyAccounts.FirstOrDefaultAsync(a => a.OrganizationId == orgId && a.Name == CashAccountName, ct);
if (cash is null)
{
cash = new MoneyAccount { OrganizationId = orgId, Name = CashAccountName, Kind = MoneyAccountKind.Cash, StoreId = storeId, CurrencyId = currencyId, OpeningBalance = 0m, OpeningDate = open, IsActive = true, IsDefault = false };
_db.MoneyAccounts.Add(cash); await _db.SaveChangesAsync(ct);
}
var bank = await _db.MoneyAccounts.FirstOrDefaultAsync(a => a.OrganizationId == orgId && a.Name == BankAccountName, ct);
if (bank is null)
{
bank = new MoneyAccount { OrganizationId = orgId, Name = BankAccountName, Kind = MoneyAccountKind.Bank, CurrencyId = currencyId, OpeningBalance = 0m, OpeningDate = open, IsActive = true, IsDefault = false };
_db.MoneyAccounts.Add(bank); await _db.SaveChangesAsync(ct);
}
var ids = (cash.Id, bank.Id); _db.ChangeTracker.Clear(); return ids;
}
/// <summary>Плуг балансов: OpeningBalance = баланс МойСклада (report/money/byaccount)
/// − Σ(перенесённых проводок счёта), чтобы текущий баланс в ALL MARKET точно совпал
/// с МойСкладом. Наличные (строка без account) → касса; account-строки → расчётный счёт.</summary>
public async Task<string> SetMoneyOpeningBalancesAsync(string token, CancellationToken ct)
{
var orgId = _tenant.OrganizationId!.Value;
var rows = await _client.GetMoneyByAccountAsync(token, ct);
if (rows is null) return "report/money/byaccount недоступен (нет прав токена?)";
var cashTarget = rows.Where(r => r.Account is null).Sum(r => r.Balance) / 100m;
var bankTarget = rows.Where(r => r.Account is not null).Sum(r => r.Balance) / 100m;
var cash = await _db.MoneyAccounts.FirstOrDefaultAsync(a => a.OrganizationId == orgId && a.Name == CashAccountName, ct);
var bank = await _db.MoneyAccounts.FirstOrDefaultAsync(a => a.OrganizationId == orgId && a.Name == BankAccountName, ct);
var sb = new System.Text.StringBuilder();
foreach (var (acc, target) in new[] { (cash, cashTarget), (bank, bankTarget) })
{
if (acc is null) continue;
var inSum = await _db.MoneyTransactions.Where(t => t.OrganizationId == orgId && t.AccountId == acc.Id && t.Status == MoneyTransactionStatus.Posted
&& (t.Kind == MoneyTransactionKind.PaymentIn || t.Kind == MoneyTransactionKind.CashIn || t.Kind == MoneyTransactionKind.TransferIn)).SumAsync(t => (decimal?)t.Amount, ct) ?? 0m;
var outSum = await _db.MoneyTransactions.Where(t => t.OrganizationId == orgId && t.AccountId == acc.Id && t.Status == MoneyTransactionStatus.Posted
&& (t.Kind == MoneyTransactionKind.PaymentOut || t.Kind == MoneyTransactionKind.CashOut || t.Kind == MoneyTransactionKind.TransferOut)).SumAsync(t => (decimal?)t.Amount, ct) ?? 0m;
acc.OpeningBalance = decimal.Round(target - (inSum - outSum), 4);
sb.Append($"{acc.Name}: цель={target:N2} Σпроводок={(inSum - outSum):N2} opening={acc.OpeningBalance:N2}; ");
}
await _db.SaveChangesAsync(ct); _db.ChangeTracker.Clear();
return sb.ToString();
}
/// <summary>Гарантирует склад орг с MoySkladExternalId = склад МойСклада (магазин
/// с одним складом). Берём первый склад орг и помечаем его msId при первом вызове.</summary>
private async Task<Guid> EnsureStoreAsync(Guid orgId, CancellationToken ct)

View file

@ -163,16 +163,6 @@ public class MsDocument
[JsonPropertyName("noCashSum")] public decimal NoCashSum { get; set; }
[JsonPropertyName("retailShift")] public MsMetaWrapper? RetailShift { get; set; }
[JsonPropertyName("demand")] public MsMetaWrapper? Demand { get; set; } // для retailsalesreturn — исходный чек
// денежные документы (платежи/ордера):
[JsonPropertyName("paymentPurpose")] public string? PaymentPurpose { get; set; }
}
// report/money/byaccount: строка без account = наличные (касса) орг, строка с account = банк. счёт.
// balance — в тиынах.
public class MsMoneyByAccountRow
{
[JsonPropertyName("account")] public MsMetaWrapper? Account { get; set; }
[JsonPropertyName("balance")] public decimal Balance { get; set; }
}
public class MsPosition