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
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:
parent
b49abfdbd7
commit
9d5c23d32c
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 $$;
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue