From 39d0243ba3dba681d2c4ac9e91d571b43ba82711 Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 9 Oct 2026 16:32:22 +0500 Subject: [PATCH] =?UTF-8?q?feat(migration):=20=D0=B4=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=B6=D0=BD=D1=8B=D0=B5=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D1=8B=20(=D0=BF=D0=BB=D0=B0=D1=82=D0=B5=D0=B6?= =?UTF-8?q?=D0=B8/=D0=BE=D1=80=D0=B4=D0=B5=D1=80=D0=B0/=D0=B2=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D0=B5=D0=BD=D0=B8=D1=8F/=D0=B2=D1=8B=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D1=82=D1=8B)=20+=20=D0=BF=D0=BB=D1=83=D0=B3=20=D0=B1=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=D1=81=D0=BE=D0=B2=20=D1=81=D1=87=D0=B5=D1=82?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ImportMoneyDocsAsync: paymentout/paymentin/cashin/cashout/retaildrawercashin/out → MoneyTransaction (история), agent→контрагент по externalId, суммы ÷100, МСК→UTC - EnsureMoneyAccountsAsync: касса + расчётный счёт орг (идемпотентно) - SetMoneyOpeningBalancesAsync: OpeningBalance = report/money/byaccount − Σпроводок, текущий баланс счёта точно совпадает с МойСкладом - GetMoneyByAccountAsync в MoySkladClient; MsMoneyByAccountRow, paymentPurpose в DTO - контроллер: type=money (все деньги+плуг), type=balances, одиночные денежные типы Co-Authored-By: Claude Opus 4.8 --- .../Admin/MoySkladImportController.cs | 19 ++++ .../Integrations/MoySklad/MoySkladClient.cs | 11 ++ .../MoySklad/MoySkladDocumentImportService.cs | 102 ++++++++++++++++++ .../Integrations/MoySklad/MoySkladDtos.cs | 10 ++ 4 files changed, 142 insertions(+) diff --git a/src/food-market.api/Controllers/Admin/MoySkladImportController.cs b/src/food-market.api/Controllers/Admin/MoySkladImportController.cs index bd4a583..c58a144 100644 --- a/src/food-market.api/Controllers/Admin/MoySkladImportController.cs +++ b/src/food-market.api/Controllers/Admin/MoySkladImportController.cs @@ -174,6 +174,25 @@ public async Task> 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; diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs index fad1555..cf782e7 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs @@ -55,6 +55,17 @@ private HttpRequestMessage Build(HttpMethod method, string pathAndQuery, string return req; } + /// report/money/byaccount — текущие остатки по деньгам (наличные + банк. счета), + /// для сверки балансов счетов при переносе. Только чтение. null при ошибке/запрете. + public async Task?> 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>(Json, ct); + return list?.Rows; + } + public async Task> WhoAmIAsync(string token, CancellationToken ct) { using var req = Build(HttpMethod.Get, "entity/organization?limit=1", token); diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDocumentImportService.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDocumentImportService.cs index a5536ee..317d92e 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDocumentImportService.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDocumentImportService.cs @@ -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(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 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(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 = "Расчётный счёт (из МойСклад)"; + + /// Гарантирует кассу (Cash) и расчётный счёт (Bank) орг. Идемпотентно по имени. + /// IsDefault=false — чтобы не конфликтовать с уникальным индексом «один default на Kind». + 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; + } + + /// Плуг балансов: OpeningBalance = баланс МойСклада (report/money/byaccount) + /// − Σ(перенесённых проводок счёта), чтобы текущий баланс в ALL MARKET точно совпал + /// с МойСкладом. Наличные (строка без account) → касса; account-строки → расчётный счёт. + public async Task 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(); + } + /// Гарантирует склад орг с MoySkladExternalId = склад МойСклада (магазин /// с одним складом). Берём первый склад орг и помечаем его msId при первом вызове. private async Task EnsureStoreAsync(Guid orgId, CancellationToken ct) diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDtos.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDtos.cs index 7d91a05..a44dade 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDtos.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDtos.cs @@ -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