Compare commits
2 commits
5d11dd7434
...
39d0243ba3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39d0243ba3 | ||
|
|
ec2a9db8e3 |
|
|
@ -2,8 +2,22 @@
|
|||
"""Сверка переноса МойСклад → ALL MARKET (только чтение обоих). Сравнивает:
|
||||
- число документов каждого типа; - остатки товар×склад (report/stock/all); - выручку и
|
||||
число чеков по месяцам. Пишет 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 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]
|
||||
|
|
@ -52,3 +66,101 @@ 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}_")
|
||||
|
|
|
|||
|
|
@ -174,6 +174,25 @@ 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;
|
||||
|
|
|
|||
|
|
@ -55,6 +55,17 @@ 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);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
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;
|
||||
|
|
@ -382,6 +383,107 @@ 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)
|
||||
|
|
|
|||
|
|
@ -163,6 +163,16 @@ 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue