feat(migration): денежные документы (платежи/ордера/внесения/выплаты) + плуг балансов счетов
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

- 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 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 16:32:22 +05:00
parent ec2a9db8e3
commit 39d0243ba3
4 changed files with 142 additions and 0 deletions

View file

@ -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;

View file

@ -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);

View file

@ -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)

View file

@ -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