diff --git a/src/food-market.api/Controllers/Admin/MoySkladImportController.cs b/src/food-market.api/Controllers/Admin/MoySkladImportController.cs index c58a144..c11074b 100644 --- a/src/food-market.api/Controllers/Admin/MoySkladImportController.cs +++ b/src/food-market.api/Controllers/Admin/MoySkladImportController.cs @@ -38,7 +38,7 @@ public record ImportRequest( string? Token = null, bool OverwriteExisting = false, bool SkipArchived = true, - bool MergeByBarcode = true); + bool MergeByBarcode = false); public record SettingsDto(bool HasToken, string? Masked); public record SettingsInput(string Token); @@ -193,6 +193,9 @@ public async Task> ImportSupplies([FromBody] ImportRequest // только пересчёт OpeningBalance (после правок проводок) case "balances": pr.ErrorSamples.Add("Балансы: " + await docs.SetMoneyOpeningBalancesAsync(token, CancellationToken.None)); break; + // выравнивание остатков к снимку report/stock/all МойСклада + case "align-stock": + await docs.AlignStockToMoySkladAsync(token, pr, 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.api/Controllers/Money/MoneyReportsController.cs b/src/food-market.api/Controllers/Money/MoneyReportsController.cs index f3e2fa7..bfcfa6f 100644 --- a/src/food-market.api/Controllers/Money/MoneyReportsController.cs +++ b/src/food-market.api/Controllers/Money/MoneyReportsController.cs @@ -208,9 +208,25 @@ public record SupplierDebtsReport(IReadOnlyList Rows, decimal T }) .ToListAsync(ct); + // Долг на уровне КОНТРАГЕНТА (как МойСклад): учитываем платежи/ордера напрямую + // по контрагенту, а не только через Supply.PayedSum — в МойСкладе paymentout/РКО + // часто не привязаны к конкретной приёмке. Исходящие (PaymentOut/CashOut) уменьшают + // наш долг поставщику, входящие (PaymentIn/CashIn) — увеличивают. + var payments = await _db.MoneyTransactions.AsNoTracking() + .Where(t => t.Status == MoneyTransactionStatus.Posted && t.CounterpartyId != null) + .GroupBy(t => t.CounterpartyId!.Value) + .Select(g => new + { + CounterpartyId = g.Key, + Out = g.Where(t => t.Kind == MoneyTransactionKind.PaymentOut || t.Kind == MoneyTransactionKind.CashOut).Sum(t => t.Amount), + In = g.Where(t => t.Kind == MoneyTransactionKind.PaymentIn || t.Kind == MoneyTransactionKind.CashIn).Sum(t => t.Amount), + }) + .ToListAsync(ct); + var supplierIds = supplies.Select(s => s.SupplierId) .Union(returns.Select(r => r.SupplierId)) .Union(adjustments.Select(a => a.CounterpartyId)) + .Union(payments.Select(p => p.CounterpartyId)) .Distinct().ToList(); var suppliers = await _db.Counterparties.AsNoTracking() @@ -224,16 +240,19 @@ public record SupplierDebtsReport(IReadOnlyList Rows, decimal T var s = supplies.FirstOrDefault(x => x.SupplierId == sup.Id); var r = returns.FirstOrDefault(x => x.SupplierId == sup.Id); var a = adjustments.FirstOrDefault(x => x.CounterpartyId == sup.Id); - // Долг = (приёмки − оплаченные приёмки) − (возвраты − полученные обратно деньги) + (корр: мы должны − они должны). - var debt = MoneyMath.SupplierDebt( - s?.Total ?? 0, s?.Paid ?? 0, - r?.Total ?? 0, r?.Paid ?? 0, - a?.WeOwe ?? 0, a?.TheyOwe ?? 0); + var pay = payments.FirstOrDefault(x => x.CounterpartyId == sup.Id); + // Долг (мы должны поставщику) на уровне контрагента, как МойСклад: + // Σ приёмок − Σ возвратов поставщику − Σ исходящих (PaymentOut/CashOut) + // + Σ входящих (PaymentIn/CashIn) + (корр: мы должны − они должны). + var paidOut = pay?.Out ?? 0; + var paidIn = pay?.In ?? 0; + var debt = (s?.Total ?? 0) - (r?.Total ?? 0) - paidOut + paidIn + + ((a?.WeOwe ?? 0) - (a?.TheyOwe ?? 0)); if (withDebtOnly && Math.Abs(debt) < MoneyMath.Eps) continue; rows.Add(new SupplierDebtRow( sup.Id, sup.Name, sup.Bin, sup.Phone, s?.Total ?? 0, r?.Total ?? 0, - r?.Paid ?? 0, s?.Paid ?? 0, + paidIn, paidOut, (a?.WeOwe ?? 0) - (a?.TheyOwe ?? 0), debt, s?.OldestUnpaid?.Date, s?.OldestUnpaid?.Number)); diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs index 23cbd1e..9b29965 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs @@ -66,6 +66,33 @@ private HttpRequestMessage Build(HttpMethod method, string pathAndQuery, string return list?.Rows; } + /// report/stock/all — текущие остатки по товарам (msId → количество). + /// НЕ отдаёт нулевые. Постранично (limit 1000). Для выравнивания остатков при переносе. + public async Task> GetStockAllAsync(string token, CancellationToken ct) + { + var res = new Dictionary(); + var offset = 0; + while (true) + { + using var req = Build(HttpMethod.Get, $"report/stock/all?limit=1000&offset={offset}", token); + using var r = await _http.SendAsync(req, ct); + if (!r.IsSuccessStatusCode) break; + var page = await r.Content.ReadFromJsonAsync>(Json, ct); + var rows = page?.Rows ?? new(); + foreach (var row in rows) + { + var href = row.Meta?.Href; + if (string.IsNullOrEmpty(href)) continue; + var id = href.Split('?')[0].TrimEnd('/').Split('/').LastOrDefault(); + if (string.IsNullOrEmpty(id)) continue; + res[id] = res.GetValueOrDefault(id) + row.Stock; + } + offset += rows.Count; + if (rows.Count < 1000 || offset >= (page?.Meta?.Size ?? 0)) break; + } + return res; + } + 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 317d92e..a4126d1 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDocumentImportService.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDocumentImportService.cs @@ -484,6 +484,46 @@ public async Task SetMoneyOpeningBalancesAsync(string token, Cancellatio return sb.ToString(); } + /// Выравнивание остатков к текущему report/stock/all МойСклада (снимок на + /// момент перехода). Для каждого товара создаёт корректирующее движение + /// на разницу (МС − наш), так что остаток точно + /// совпадает с МойСкладом, при этом остаётся инвариант stock = Σдвижений и аудит. + /// Нужно для карточек, чьи позиции в документах не легли (раньше сливались по ШК) — + /// их движения из истории восстановить нельзя, поэтому берём снимок МС как «начальный + /// остаток при переносе». Идемпотентно: при повторном запуске разница 0 → ничего. + public async Task AlignStockToMoySkladAsync(string token, DocImportProgress progress, CancellationToken ct) + { + var orgId = _tenant.OrganizationId!.Value; progress.Entity = "stock-align"; + var storeId = await EnsureStoreAsync(orgId, ct); + var msStock = await _client.GetStockAllAsync(token, ct); + var prods = await _db.Products.Where(p => p.OrganizationId == orgId && p.MoySkladExternalId != null) + .Select(p => new { p.Id, p.MoySkladExternalId }).ToListAsync(ct); + var byMs = prods.ToDictionary(x => x.MoySkladExternalId!, x => x.Id); + // текущий остаток по товару (сумма по складам) + var cur = (await _db.Stocks.Where(s => s.OrganizationId == orgId) + .GroupBy(s => s.ProductId).Select(g => new { ProductId = g.Key, Q = g.Sum(x => x.Quantity) }).ToListAsync(ct)) + .ToDictionary(x => x.ProductId, x => x.Q); + var now = DateTime.UtcNow; + // Кандидаты: все товары с msId (чтобы занулить то, чего в МС уже нет, и добить недостающее). + foreach (var p in prods) + { + progress.Seen++; + var target = msStock.GetValueOrDefault(p.MoySkladExternalId!, 0m); + var current = cur.GetValueOrDefault(p.Id, 0m); + var delta = target - current; + if (Math.Abs(delta) < 0.0001m) { progress.Skipped++; continue; } + try + { + await _stock.ApplyMovementAsync(new StockMovementDraft(p.Id, storeId, delta, MovementType.Initial, + "migration-align", DocumentNumber: "МС-выравнивание", OccurredAt: now, + Notes: "Выравнивание остатка к МойСкладу при переносе"), ct); + await _db.SaveChangesAsync(ct); _db.ChangeTracker.Clear(); progress.Created++; + } + catch (Exception ex) { _db.ChangeTracker.Clear(); progress.Errors++; if (progress.ErrorSamples.Count < 20) progress.ErrorSamples.Add($"{p.MoySkladExternalId}: {ex.Message}"); _log.LogWarning(ex, "align {Id}", p.MoySkladExternalId); } + } + return progress; + } + /// Гарантирует склад орг с 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 a44dade..20c9180 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDtos.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDtos.cs @@ -167,6 +167,13 @@ public class MsDocument [JsonPropertyName("paymentPurpose")] public string? PaymentPurpose { get; set; } } +// report/stock/all: строка остатка по товару. meta.href → товар (или вариант). +public class MsStockRow +{ + [JsonPropertyName("meta")] public MsMeta? Meta { get; set; } + [JsonPropertyName("stock")] public decimal Stock { get; set; } +} + // report/money/byaccount: строка без account = наличные (касса) орг, строка с account = банк. счёт. // balance — в тиынах. public class MsMoneyByAccountRow diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs index 73b6290..df1af5a 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs @@ -40,7 +40,9 @@ public record MoySkladImportOptions /// Если локально уже есть товар с таким же штрихкодом (а в /// MoySklad он с другим external id) — обновлять его как merge, а /// не создавать второй. - public bool MergeByBarcode { get; init; } = true; + // «Минимум как МойСклад»: по умолчанию НЕ сливаем карточки по совпадению ШК + // (в МС дубли ШК допустимы). Слияние можно включить явно (legacy-поведение). + public bool MergeByBarcode { get; init; } = false; } public class MoySkladImportService @@ -340,17 +342,17 @@ await foreach (var p in _client.StreamProductsAsync(token, ct, includeArchived: { var barcodes = ExtractBarcodes(p); - // 1) Если какой-то из barcodes этого товара уже встречался во - // входе под другим именем — пропускаем товар целиком. + // 1) «Минимум как МойСклад»: ШК может быть у НЕСКОЛЬКИХ карточек. + // НЕ пропускаем товар с дублирующим ШК — переносим каждую карточку + // отдельно (уникальность ШК снята, Phase43). Фиксируем факт дубля + // для отчёта (его потом видно в «Проверке данных» → dup-barcode). var dupeInInput = barcodes .Select(b => b.Code) .FirstOrDefault(c => seenBarcodesInInput.ContainsKey(c) && seenBarcodesInInput[c] != p.Name); if (dupeInInput is not null) { - skippedDupeBarcodeInMs++; - errors.Add($"MS-дубль: товар «{p.Name}» имеет штрихкод {dupeInInput}, уже привязанный к «{seenBarcodesInInput[dupeInInput]}» — пропущен."); + skippedDupeBarcodeInMs++; // счётчик теперь = «карточек с дублем ШК» (не пропущенных) if (progress is not null) progress.Errors = errors; - continue; } foreach (var b in barcodes) seenBarcodesInInput.TryAdd(b.Code, p.Name); diff --git a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs index 29f3d97..c20d64c 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs @@ -194,7 +194,10 @@ private static void ConfigureBarcode(EntityTypeBuilder b) // Up to 500 to accommodate GS1 DataMatrix / crypto-tail tracking codes (Честный ЗНАК etc.) b.Property(x => x.Code).HasMaxLength(500).IsRequired(); b.HasOne(x => x.Product).WithMany(p => p.Barcodes).HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Cascade); - b.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique(); + // Штрихкод НЕ уникален в орг: «минимум как МойСклад» — там допустимы дубли ШК + // (один код на несколько карточек). Уникальность снята (Phase43); дубли ловит + // «Проверка данных» (dup-barcode), на кассе/в приёмке при скане — окно выбора товара. + b.HasIndex(x => new { x.OrganizationId, x.Code }); } private static void ConfigureImage(EntityTypeBuilder b) diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261009200000_Phase44_SoftBarcode.cs b/src/food-market.infrastructure/Persistence/Migrations/20261009200000_Phase44_SoftBarcode.cs new file mode 100644 index 0000000..814f406 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261009200000_Phase44_SoftBarcode.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase43 — штрихкод БОЛЬШЕ НЕ уникален в организации («минимум как + /// МойСклад»: там один ШК может быть на нескольких карточках). Снимаем UNIQUE + /// с индекса (OrganizationId, Code), оставляем обычный индекс для поиска. + /// Идемпотентно. Дубли ШК выявляет «Проверка данных» (dup-barcode), на кассе/ + /// в приёмке при скане неоднозначного ШК показывается окно выбора товара. + [DbContext(typeof(AppDbContext))] + [Migration("20261009200000_Phase44_SoftBarcode")] + public partial class Phase44_SoftBarcode : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(@" + DROP INDEX IF EXISTS public.""IX_product_barcodes_OrganizationId_Code""; + CREATE INDEX IF NOT EXISTS ""IX_product_barcodes_OrganizationId_Code"" + ON public.product_barcodes (""OrganizationId"", ""Code""); + "); + } + + protected override void Down(MigrationBuilder b) + { + // Возврат к UNIQUE возможен только если дублей нет; на проде их может уже + // не быть — но чтобы Down не падал, пытаемся создать unique, а при конфликте + // оставляем обычный индекс. + b.Sql(@" + DROP INDEX IF EXISTS public.""IX_product_barcodes_OrganizationId_Code""; + DO $$ + BEGIN + CREATE UNIQUE INDEX ""IX_product_barcodes_OrganizationId_Code"" + ON public.product_barcodes (""OrganizationId"", ""Code""); + EXCEPTION WHEN unique_violation OR duplicate_table THEN + CREATE INDEX IF NOT EXISTS ""IX_product_barcodes_OrganizationId_Code"" + ON public.product_barcodes (""OrganizationId"", ""Code""); + END $$; + "); + } + } +} diff --git a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 3a50c3c..f88cee2 100644 --- a/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/food-market.infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -680,8 +680,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ProductId"); - b.HasIndex("OrganizationId", "Code") - .IsUnique(); + b.HasIndex("OrganizationId", "Code"); b.ToTable("product_barcodes", "public"); });