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
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:
parent
ec2a9db8e3
commit
39d0243ba3
|
|
@ -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 "retaildemand": await docs.ImportRetailSalesAsync(token, startOffset, pr, CancellationToken.None, updFrom); break;
|
||||||
case "retailsalesreturn": await docs.ImportRetailReturnsAsync(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;
|
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;
|
default: await docs.ImportSuppliesAsync(token, startOffset, pr, CancellationToken.None, updFrom); break;
|
||||||
}
|
}
|
||||||
job.Total = pr.Seen; job.Created = pr.Created; job.Skipped = pr.Skipped;
|
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;
|
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)
|
public async Task<MoySkladApiResult<MsOrganization>> WhoAmIAsync(string token, CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var req = Build(HttpMethod.Get, "entity/organization?limit=1", token);
|
using var req = Build(HttpMethod.Get, "entity/organization?limit=1", token);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
using foodmarket.Application.Common.Tenancy;
|
using foodmarket.Application.Common.Tenancy;
|
||||||
using foodmarket.Application.Inventory;
|
using foodmarket.Application.Inventory;
|
||||||
using foodmarket.Domain.Inventory;
|
using foodmarket.Domain.Inventory;
|
||||||
|
using foodmarket.Domain.Money;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
@ -382,6 +383,107 @@ await foreach (var d in _client.StreamDocumentsAsync<MsDocument>(token, "entity/
|
||||||
return progress;
|
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 = склад МойСклада (магазин
|
/// <summary>Гарантирует склад орг с MoySkladExternalId = склад МойСклада (магазин
|
||||||
/// с одним складом). Берём первый склад орг и помечаем его msId при первом вызове.</summary>
|
/// с одним складом). Берём первый склад орг и помечаем его msId при первом вызове.</summary>
|
||||||
private async Task<Guid> EnsureStoreAsync(Guid orgId, CancellationToken ct)
|
private async Task<Guid> EnsureStoreAsync(Guid orgId, CancellationToken ct)
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,16 @@ public class MsDocument
|
||||||
[JsonPropertyName("noCashSum")] public decimal NoCashSum { get; set; }
|
[JsonPropertyName("noCashSum")] public decimal NoCashSum { get; set; }
|
||||||
[JsonPropertyName("retailShift")] public MsMetaWrapper? RetailShift { get; set; }
|
[JsonPropertyName("retailShift")] public MsMetaWrapper? RetailShift { get; set; }
|
||||||
[JsonPropertyName("demand")] public MsMetaWrapper? Demand { get; set; } // для retailsalesreturn — исходный чек
|
[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
|
public class MsPosition
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue