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,
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<ActionResult<object>> 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;

View file

@ -208,9 +208,25 @@ public record SupplierDebtsReport(IReadOnlyList<SupplierDebtRow> 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<SupplierDebtRow> 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));

View file

@ -66,6 +66,33 @@ private HttpRequestMessage Build(HttpMethod method, string pathAndQuery, string
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)
{
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();
}
/// <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 = склад МойСклада (магазин
/// с одним складом). Берём первый склад орг и помечаем его msId при первом вызове.</summary>
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; }
}
// 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

View file

@ -40,7 +40,9 @@ public record MoySkladImportOptions
/// <summary>Если локально уже есть товар с таким же штрихкодом (а в
/// MoySklad он с другим external id) — обновлять его как merge, а
/// не создавать второй.</summary>
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);

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.)
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<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("OrganizationId", "Code")
.IsUnique();
b.HasIndex("OrganizationId", "Code");
b.ToTable("product_barcodes", "public");
});