fix(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

- Phase44: снята UNIQUE с (OrganizationId,Code) product_barcodes — «минимум как МойСклад»;
  импорт больше НЕ сливает/не пропускает карточки с дублем ШК (MergeByBarcode default=false,
  dupeInInput не пропускает). Дубли ловит «Проверка данных» (dup-barcode, уже есть).
- SupplierDebts: долг на уровне КОНТРАГЕНТА = Σприёмок − Σвозвратов − ΣисходящихРКО + Σвходящих
  + корректировки (учёт paymentout/cashout/paymentin/cashin по CounterpartyId).
- AlignStockToMoySkladAsync (type=align-stock): корректирующие Initial-движения на разницу
  (МС report/stock/all − наш остаток) → остаток точно = МойСклад, инвариант stock=Σдвижений.
- GetStockAllAsync в клиенте; MsStockRow DTO.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 20:00:34 +05:00
parent b49abfdbd7
commit 9d5c23d32c
9 changed files with 161 additions and 16 deletions

View file

@ -38,7 +38,7 @@ public record ImportRequest(
string? Token = null, string? Token = null,
bool OverwriteExisting = false, bool OverwriteExisting = false,
bool SkipArchived = true, bool SkipArchived = true,
bool MergeByBarcode = true); bool MergeByBarcode = false);
public record SettingsDto(bool HasToken, string? Masked); public record SettingsDto(bool HasToken, string? Masked);
public record SettingsInput(string Token); public record SettingsInput(string Token);
@ -193,6 +193,9 @@ public async Task<ActionResult<object>> ImportSupplies([FromBody] ImportRequest
// только пересчёт OpeningBalance (после правок проводок) // только пересчёт OpeningBalance (после правок проводок)
case "balances": case "balances":
pr.ErrorSamples.Add("Балансы: " + await docs.SetMoneyOpeningBalancesAsync(token, CancellationToken.None)); break; 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; 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;

View file

@ -208,9 +208,25 @@ public record SupplierDebtsReport(IReadOnlyList<SupplierDebtRow> Rows, decimal T
}) })
.ToListAsync(ct); .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) var supplierIds = supplies.Select(s => s.SupplierId)
.Union(returns.Select(r => r.SupplierId)) .Union(returns.Select(r => r.SupplierId))
.Union(adjustments.Select(a => a.CounterpartyId)) .Union(adjustments.Select(a => a.CounterpartyId))
.Union(payments.Select(p => p.CounterpartyId))
.Distinct().ToList(); .Distinct().ToList();
var suppliers = await _db.Counterparties.AsNoTracking() var suppliers = await _db.Counterparties.AsNoTracking()
@ -224,16 +240,19 @@ public record SupplierDebtsReport(IReadOnlyList<SupplierDebtRow> Rows, decimal T
var s = supplies.FirstOrDefault(x => x.SupplierId == sup.Id); var s = supplies.FirstOrDefault(x => x.SupplierId == sup.Id);
var r = returns.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 a = adjustments.FirstOrDefault(x => x.CounterpartyId == sup.Id);
// Долг = (приёмки − оплаченные приёмки) − (возвраты − полученные обратно деньги) + (корр: мы должны − они должны). var pay = payments.FirstOrDefault(x => x.CounterpartyId == sup.Id);
var debt = MoneyMath.SupplierDebt( // Долг (мы должны поставщику) на уровне контрагента, как МойСклад:
s?.Total ?? 0, s?.Paid ?? 0, // Σ приёмок − Σ возвратов поставщику − Σ исходящих (PaymentOut/CashOut)
r?.Total ?? 0, r?.Paid ?? 0, // + Σ входящих (PaymentIn/CashIn) + (корр: мы должны − они должны).
a?.WeOwe ?? 0, a?.TheyOwe ?? 0); 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; if (withDebtOnly && Math.Abs(debt) < MoneyMath.Eps) continue;
rows.Add(new SupplierDebtRow( rows.Add(new SupplierDebtRow(
sup.Id, sup.Name, sup.Bin, sup.Phone, sup.Id, sup.Name, sup.Bin, sup.Phone,
s?.Total ?? 0, r?.Total ?? 0, s?.Total ?? 0, r?.Total ?? 0,
r?.Paid ?? 0, s?.Paid ?? 0, paidIn, paidOut,
(a?.WeOwe ?? 0) - (a?.TheyOwe ?? 0), (a?.WeOwe ?? 0) - (a?.TheyOwe ?? 0),
debt, debt,
s?.OldestUnpaid?.Date, s?.OldestUnpaid?.Number)); s?.OldestUnpaid?.Date, s?.OldestUnpaid?.Number));

View file

@ -66,6 +66,33 @@ private HttpRequestMessage Build(HttpMethod method, string pathAndQuery, string
return list?.Rows; return list?.Rows;
} }
/// <summary>report/stock/all — текущие остатки по товарам (msId → количество).
/// НЕ отдаёт нулевые. Постранично (limit 1000). Для выравнивания остатков при переносе.</summary>
public async Task<Dictionary<string, decimal>> GetStockAllAsync(string token, CancellationToken ct)
{
var res = new Dictionary<string, decimal>();
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<MsListResponse<MsStockRow>>(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<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);

View file

@ -484,6 +484,46 @@ public async Task<string> SetMoneyOpeningBalancesAsync(string token, Cancellatio
return sb.ToString(); return sb.ToString();
} }
/// <summary>Выравнивание остатков к текущему report/stock/all МойСклада (снимок на
/// момент перехода). Для каждого товара создаёт корректирующее движение
/// <see cref="MovementType.Initial"/> на разницу (МС − наш), так что остаток точно
/// совпадает с МойСкладом, при этом остаётся инвариант stock = Σдвижений и аудит.
/// Нужно для карточек, чьи позиции в документах не легли (раньше сливались по ШК) —
/// их движения из истории восстановить нельзя, поэтому берём снимок МС как «начальный
/// остаток при переносе». Идемпотентно: при повторном запуске разница 0 → ничего.</summary>
public async Task<DocImportProgress> 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;
}
/// <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)

View file

@ -167,6 +167,13 @@ public class MsDocument
[JsonPropertyName("paymentPurpose")] public string? PaymentPurpose { get; set; } [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 = банк. счёт. // report/money/byaccount: строка без account = наличные (касса) орг, строка с account = банк. счёт.
// balance — в тиынах. // balance — в тиынах.
public class MsMoneyByAccountRow public class MsMoneyByAccountRow

View file

@ -40,7 +40,9 @@ public record MoySkladImportOptions
/// <summary>Если локально уже есть товар с таким же штрихкодом (а в /// <summary>Если локально уже есть товар с таким же штрихкодом (а в
/// MoySklad он с другим external id) — обновлять его как merge, а /// MoySklad он с другим external id) — обновлять его как merge, а
/// не создавать второй.</summary> /// не создавать второй.</summary>
public bool MergeByBarcode { get; init; } = true; // «Минимум как МойСклад»: по умолчанию НЕ сливаем карточки по совпадению ШК
// (в МС дубли ШК допустимы). Слияние можно включить явно (legacy-поведение).
public bool MergeByBarcode { get; init; } = false;
} }
public class MoySkladImportService public class MoySkladImportService
@ -340,17 +342,17 @@ await foreach (var p in _client.StreamProductsAsync(token, ct, includeArchived:
{ {
var barcodes = ExtractBarcodes(p); var barcodes = ExtractBarcodes(p);
// 1) Если какой-то из barcodes этого товара уже встречался во // 1) «Минимум как МойСклад»: ШК может быть у НЕСКОЛЬКИХ карточек.
// входе под другим именем — пропускаем товар целиком. // НЕ пропускаем товар с дублирующим ШК — переносим каждую карточку
// отдельно (уникальность ШК снята, Phase43). Фиксируем факт дубля
// для отчёта (его потом видно в «Проверке данных» → dup-barcode).
var dupeInInput = barcodes var dupeInInput = barcodes
.Select(b => b.Code) .Select(b => b.Code)
.FirstOrDefault(c => seenBarcodesInInput.ContainsKey(c) && seenBarcodesInInput[c] != p.Name); .FirstOrDefault(c => seenBarcodesInInput.ContainsKey(c) && seenBarcodesInInput[c] != p.Name);
if (dupeInInput is not null) if (dupeInInput is not null)
{ {
skippedDupeBarcodeInMs++; skippedDupeBarcodeInMs++; // счётчик теперь = «карточек с дублем ШК» (не пропущенных)
errors.Add($"MS-дубль: товар «{p.Name}» имеет штрихкод {dupeInInput}, уже привязанный к «{seenBarcodesInInput[dupeInInput]}» — пропущен.");
if (progress is not null) progress.Errors = errors; if (progress is not null) progress.Errors = errors;
continue;
} }
foreach (var b in barcodes) seenBarcodesInInput.TryAdd(b.Code, p.Name); foreach (var b in barcodes) seenBarcodesInInput.TryAdd(b.Code, p.Name);

View file

@ -194,7 +194,10 @@ private static void ConfigureBarcode(EntityTypeBuilder<ProductBarcode> b)
// Up to 500 to accommodate GS1 DataMatrix / crypto-tail tracking codes (Честный ЗНАК etc.) // Up to 500 to accommodate GS1 DataMatrix / crypto-tail tracking codes (Честный ЗНАК etc.)
b.Property(x => x.Code).HasMaxLength(500).IsRequired(); 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.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<ProductImage> b) private static void ConfigureImage(EntityTypeBuilder<ProductImage> b)

View file

@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase43 — штрихкод БОЛЬШЕ НЕ уникален в организации («минимум как
/// МойСклад»: там один ШК может быть на нескольких карточках). Снимаем UNIQUE
/// с индекса (OrganizationId, Code), оставляем обычный индекс для поиска.
/// Идемпотентно. Дубли ШК выявляет «Проверка данных» (dup-barcode), на кассе/
/// в приёмке при скане неоднозначного ШК показывается окно выбора товара.</summary>
[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 $$;
");
}
}
}

View file

@ -680,8 +680,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasIndex("ProductId"); b.HasIndex("ProductId");
b.HasIndex("OrganizationId", "Code") b.HasIndex("OrganizationId", "Code");
.IsUnique();
b.ToTable("product_barcodes", "public"); b.ToTable("product_barcodes", "public");
}); });