Compare commits

..

No commits in common. "417030827fc8d4b98b657d369162f91fb3bdd18b" and "a517c32fee04b7fad2f483c0b25a7984568cc398" have entirely different histories.

3 changed files with 6 additions and 175 deletions

View file

@ -27,18 +27,9 @@
fallback), 7385 проведённых с движениями остатков. Починены 5 багов: sum как decimal fallback), 7385 проведённых с движениями остатков. Починены 5 багов: sum как decimal
(«900000.0»), тяжёлый expand, дубль stocks при батче, каскад после сбоя SaveChanges, («900000.0»), тяжёлый expand, дубль stocks при батче, каскад после сбоя SaveChanges,
дубль номеров (МойСклад повторяет по годам → дизамбигуация «номер-год»). дубль номеров (МойСклад повторяет по годам → дизамбигуация «номер-год»).
- [x] **Оприходования (enter) — 72/72** перенесены и проверены (даты/суммы/статус). - [ ] Остальные типы по ТОМУ ЖЕ шаблону: retaildemand (97k, +оплата нал/безнал, смена,
- [x] **Розничные продажи (retaildemand) — мигратор работает и ПРОВЕРЕН**: оплата кассир), retailsalesreturn (связь с чеком), loss, enter, inventory, supplier_returns,
нал/безнал из cashSum/noCashSum верна (сверено с МойСкладом: первые чеки noCashSum→Card), retailshift, внесения/выплаты/ордера/платежи. Объём большой (97k чеков ≈ 2.5ч прогона).
даты МСК→UTC, суммы из тиынов, движения остатков (−). Запущен полный прогон 97 707
(фон, ~2.5ч); идёт корректно.
- [x] Списания (loss, −остаток), возвраты поставщику (purchasereturn, −остаток) —
мигратор написан по шаблону (запуск ПОСЛЕ retaildemand — параллельно нельзя, гонка
остатков по одной орг).
- [ ] retailsalesreturn (связь с чеком по externalId), inventory, retailshift,
внесения/выплаты/ордера/платежи — по шаблону (следующая итерация).
- Эндпоинт `POST /import-supplies?type=enter|loss|purchasereturn|retaildemand` (фон).
ВАЖНО: запускать типы ПОСЛЕДОВАТЕЛЬНО (не параллельно — одна орг, гонка stocks).
- [ ] uom/склад/точка/сотрудники как отдельные справочники с externalId (пока товары на - [ ] uom/склад/точка/сотрудники как отдельные справочники с externalId (пока товары на
дефолтных ед.; склад — единственный склад орг). дефолтных ед.; склад — единственный склад орг).
3. [x] **Новая орг на стенде** «FOOD MARKET» (простой режим), admin `foodmarket-migr@food-market.local`. 3. [x] **Новая орг на стенде** «FOOD MARKET» (простой режим), admin `foodmarket-migr@food-market.local`.

View file

@ -150,9 +150,8 @@ public async Task<ActionResult<object>> ImportSupplies([FromBody] ImportRequest
if (string.IsNullOrWhiteSpace(token)) if (string.IsNullOrWhiteSpace(token))
return BadRequest(new { error = "Токен не настроен." }); return BadRequest(new { error = "Токен не настроен." });
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var type = HttpContext.Request.Query["type"].ToString(); // захватываем ДО Task.Run (HttpContext потом недоступен) var job = _jobs.Create("supplies");
var job = _jobs.Create(string.IsNullOrEmpty(type) ? "supplies" : type); job.Stage = "Перенос приёмок (с историей)…";
job.Stage = $"Перенос документов ({(string.IsNullOrEmpty(type) ? "приёмки" : type)}) с историей…";
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
using var flush = new Timer(_ => { try { using var s = HttpContextTenantContext.UseOverride(orgId); _ = _jobs.SaveAsync(job); } catch { } }, using var flush = new Timer(_ => { try { using var s = HttpContextTenantContext.UseOverride(orgId); _ = _jobs.SaveAsync(job); } catch { } },
@ -163,14 +162,7 @@ public async Task<ActionResult<object>> ImportSupplies([FromBody] ImportRequest
using var scope = _scopes.CreateScope(); using var scope = _scopes.CreateScope();
var docs = scope.ServiceProvider.GetRequiredService<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladDocumentImportService>(); var docs = scope.ServiceProvider.GetRequiredService<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladDocumentImportService>();
var pr = new foodmarket.Infrastructure.Integrations.MoySklad.MoySkladDocumentImportService.DocImportProgress(); var pr = new foodmarket.Infrastructure.Integrations.MoySklad.MoySkladDocumentImportService.DocImportProgress();
switch (type) await docs.ImportSuppliesAsync(token, 0, pr, CancellationToken.None);
{
case "enter": await docs.ImportEntersAsync(token, pr, CancellationToken.None); break;
case "loss": await docs.ImportLossesAsync(token, pr, CancellationToken.None); break;
case "purchasereturn": await docs.ImportSupplierReturnsAsync(token, pr, CancellationToken.None); break;
case "retaildemand": await docs.ImportRetailSalesAsync(token, pr, CancellationToken.None); break;
default: await docs.ImportSuppliesAsync(token, 0, pr, CancellationToken.None); 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;
job.Message = $"Приёмки: перенесено {pr.Created}, пропущено {pr.Skipped}, ошибок {pr.Errors}. " + job.Message = $"Приёмки: перенесено {pr.Created}, пропущено {pr.Skipped}, ошибок {pr.Errors}. " +
(pr.ErrorSamples.Count > 0 ? "Примеры: " + string.Join(" | ", pr.ErrorSamples.Take(5)) : ""); (pr.ErrorSamples.Count > 0 ? "Примеры: " + string.Join(" | ", pr.ErrorSamples.Take(5)) : "");

View file

@ -163,158 +163,6 @@ string UniqueNumber(string baseName, int year)
return progress; return progress;
} }
// ── общие помощники ──────────────────────────────────────────────────────
private static Dictionary<Guid, (decimal Qty, decimal Amount)> Aggregate(MsDocument d, Dictionary<string, Guid> products)
{
var byProduct = new Dictionary<Guid, (decimal Qty, decimal Amount)>();
foreach (var pos in d.Positions?.Rows ?? new())
{
var prodMs = IdFromHref(pos.Assortment?.Meta?.Href);
if (prodMs is null || !products.TryGetValue(prodMs, out var pid)) continue;
var up = pos.Price / 100m;
var cur = byProduct.GetValueOrDefault(pid);
byProduct[pid] = (cur.Qty + pos.Quantity, cur.Amount + pos.Quantity * up);
}
return byProduct;
}
private sealed record DocMaps(Guid StoreId, Guid CurrencyId, Guid? AdminUserId,
Dictionary<string, Guid> Products, Dictionary<string, Guid> Counterparties, Guid? FallbackCp);
private async Task<DocMaps> LoadMapsAsync(Guid orgId, CancellationToken ct)
{
var currencyId = await _db.Currencies.Select(c => c.Id).FirstAsync(ct);
var storeId = await EnsureStoreAsync(orgId, ct);
var admin = await _db.Organizations.Where(o => o.Id == orgId).Select(o => o.AccountOwnerUserId).FirstOrDefaultAsync(ct);
var cps = await _db.Counterparties.Where(c => c.MoySkladExternalId != null)
.Select(c => new { c.Id, c.MoySkladExternalId }).ToDictionaryAsync(x => x.MoySkladExternalId!, x => x.Id, ct);
var fb = await _db.Counterparties.Select(c => (Guid?)c.Id).FirstOrDefaultAsync(ct);
var prods = await _db.Products.Where(p => p.MoySkladExternalId != null)
.Select(p => new { p.Id, p.MoySkladExternalId }).ToDictionaryAsync(x => x.MoySkladExternalId!, x => x.Id, ct);
return new DocMaps(storeId, currencyId, admin, prods, cps, fb);
}
// ── Оприходования (enter) ────────────────────────────────────────────────
public async Task<DocImportProgress> ImportEntersAsync(string token, DocImportProgress progress, CancellationToken ct)
{
var orgId = _tenant.OrganizationId!.Value; progress.Entity = "enter";
var m = await LoadMapsAsync(orgId, ct);
var done = (await _db.Enters.Where(x => x.MoySkladExternalId != null).Select(x => x.MoySkladExternalId!).ToListAsync(ct)).ToHashSet();
var used = (await _db.Enters.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/enter", "positions,store", 0, null, ct))
{
progress.Seen++; if (d.Id is null || done.Contains(d.Id)) { progress.Skipped++; continue; }
try
{
var mom = MomentToUtc(d.Moment); var posted = d.Applicable;
var e = new foodmarket.Domain.Purchases.Enter { OrganizationId = orgId, Number = Uniq(d.Name ?? $"МС-{d.Id[..8]}", mom.Year), Date = mom,
Status = posted ? foodmarket.Domain.Purchases.EnterStatus.Posted : foodmarket.Domain.Purchases.EnterStatus.Draft,
StoreId = m.StoreId, CurrencyId = m.CurrencyId, Notes = d.Description, Total = d.Sum / 100m, MoySkladExternalId = d.Id,
PostedAt = posted ? mom : null, PostedByUserId = posted ? m.AdminUserId : null };
foreach (var (pid, a) in Aggregate(d, m.Products))
e.Lines.Add(new foodmarket.Domain.Purchases.EnterLine { OrganizationId = orgId, ProductId = pid, Quantity = a.Qty, UnitCost = a.Qty != 0 ? decimal.Round(a.Amount / a.Qty, 4) : 0m, LineTotal = decimal.Round(a.Amount, 2) });
_db.Enters.Add(e);
if (posted) foreach (var l in e.Lines) await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, m.StoreId, l.Quantity, MovementType.Enter, "enter", e.Id, e.Number, l.UnitCost, mom, m.AdminUserId), ct);
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, "enter {Id}", d.Id); }
}
return progress;
}
// ── Списания (loss) ──────────────────────────────────────────────────────
public async Task<DocImportProgress> ImportLossesAsync(string token, DocImportProgress progress, CancellationToken ct)
{
var orgId = _tenant.OrganizationId!.Value; progress.Entity = "loss";
var m = await LoadMapsAsync(orgId, ct);
var done = (await _db.Losses.Where(x => x.MoySkladExternalId != null).Select(x => x.MoySkladExternalId!).ToListAsync(ct)).ToHashSet();
var used = (await _db.Losses.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/loss", "positions,store", 0, null, ct))
{
progress.Seen++; if (d.Id is null || done.Contains(d.Id)) { progress.Skipped++; continue; }
try
{
var mom = MomentToUtc(d.Moment); var posted = d.Applicable;
var e = new foodmarket.Domain.Inventory.Loss { OrganizationId = orgId, Number = Uniq(d.Name ?? $"МС-{d.Id[..8]}", mom.Year), Date = mom,
Status = posted ? foodmarket.Domain.Inventory.LossStatus.Posted : foodmarket.Domain.Inventory.LossStatus.Draft,
StoreId = m.StoreId, CurrencyId = m.CurrencyId, Reason = foodmarket.Domain.Inventory.LossReason.Other, Notes = d.Description,
Total = d.Sum / 100m, MoySkladExternalId = d.Id, PostedAt = posted ? mom : null, PostedByUserId = posted ? m.AdminUserId : null };
foreach (var (pid, a) in Aggregate(d, m.Products))
e.Lines.Add(new foodmarket.Domain.Inventory.LossLine { OrganizationId = orgId, ProductId = pid, Quantity = a.Qty, UnitCost = a.Qty != 0 ? decimal.Round(a.Amount / a.Qty, 4) : 0m, LineTotal = decimal.Round(a.Amount, 2) });
_db.Losses.Add(e);
if (posted) foreach (var l in e.Lines) await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, m.StoreId, -l.Quantity, MovementType.WriteOff, "loss", e.Id, e.Number, l.UnitCost, mom, m.AdminUserId), ct);
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, "loss {Id}", d.Id); }
}
return progress;
}
// ── Возвраты поставщику (purchasereturn) ─────────────────────────────────
public async Task<DocImportProgress> ImportSupplierReturnsAsync(string token, DocImportProgress progress, CancellationToken ct)
{
var orgId = _tenant.OrganizationId!.Value; progress.Entity = "purchasereturn";
var m = await LoadMapsAsync(orgId, ct);
var done = (await _db.SupplierReturns.Where(x => x.MoySkladExternalId != null).Select(x => x.MoySkladExternalId!).ToListAsync(ct)).ToHashSet();
var used = (await _db.SupplierReturns.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/purchasereturn", "positions,agent,store", 0, null, ct))
{
progress.Seen++; if (d.Id is null || done.Contains(d.Id)) { progress.Skipped++; continue; }
try
{
var mom = MomentToUtc(d.Moment); var posted = d.Applicable;
var cpMs = IdFromHref(d.Agent?.Meta?.Href);
var cpId = cpMs is not null && m.Counterparties.TryGetValue(cpMs, out var c1) ? c1 : m.FallbackCp!.Value;
var e = new foodmarket.Domain.Purchases.SupplierReturn { OrganizationId = orgId, Number = Uniq(d.Name ?? $"МС-{d.Id[..8]}", mom.Year), Date = mom,
Status = posted ? foodmarket.Domain.Purchases.SupplierReturnStatus.Posted : foodmarket.Domain.Purchases.SupplierReturnStatus.Draft,
SupplierId = cpId, StoreId = m.StoreId, CurrencyId = m.CurrencyId, Notes = d.Description, Total = d.Sum / 100m,
MoySkladExternalId = d.Id, PostedAt = posted ? mom : null, PostedByUserId = posted ? m.AdminUserId : null };
foreach (var (pid, a) in Aggregate(d, m.Products))
e.Lines.Add(new foodmarket.Domain.Purchases.SupplierReturnLine { OrganizationId = orgId, ProductId = pid, Quantity = a.Qty, UnitPrice = a.Qty != 0 ? decimal.Round(a.Amount / a.Qty, 4) : 0m, LineTotal = decimal.Round(a.Amount, 2) });
_db.SupplierReturns.Add(e);
if (posted) foreach (var l in e.Lines) await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, m.StoreId, -l.Quantity, MovementType.SupplierReturn, "supplier-return", e.Id, e.Number, l.UnitPrice, mom, m.AdminUserId), ct);
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, "purchasereturn {Id}", d.Id); }
}
return progress;
}
// ── Розничные продажи (retaildemand) ─────────────────────────────────────
public async Task<DocImportProgress> ImportRetailSalesAsync(string token, DocImportProgress progress, CancellationToken ct)
{
var orgId = _tenant.OrganizationId!.Value; progress.Entity = "retaildemand";
var m = await LoadMapsAsync(orgId, ct);
var done = (await _db.RetailSales.Where(x => x.MoySkladExternalId != null).Select(x => x.MoySkladExternalId!).ToListAsync(ct)).ToHashSet();
var used = (await _db.RetailSales.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/retaildemand", "positions,store", 0, null, ct))
{
progress.Seen++; if (d.Id is null || done.Contains(d.Id)) { progress.Skipped++; continue; }
try
{
var mom = MomentToUtc(d.Moment); var posted = d.Applicable;
var cash = d.CashSum / 100m; var card = d.NoCashSum / 100m; var total = d.Sum / 100m;
var pay = cash > 0 && card > 0 ? foodmarket.Domain.Sales.PaymentMethod.Mixed : (card > 0 ? foodmarket.Domain.Sales.PaymentMethod.Card : foodmarket.Domain.Sales.PaymentMethod.Cash);
var e = new foodmarket.Domain.Sales.RetailSale { OrganizationId = orgId, Number = Uniq(d.Name ?? $"МС-{d.Id[..8]}", mom.Year), Date = mom,
Status = posted ? foodmarket.Domain.Sales.RetailSaleStatus.Posted : foodmarket.Domain.Sales.RetailSaleStatus.Draft,
StoreId = m.StoreId, CurrencyId = m.CurrencyId, CashierUserId = m.AdminUserId, Payment = pay, PaidCash = cash, PaidCard = card,
Subtotal = total, Total = total, Notes = d.Description, MoySkladExternalId = d.Id, IsReturn = false,
PostedAt = posted ? mom : null, PostedByUserId = posted ? m.AdminUserId : null };
foreach (var (pid, a) in Aggregate(d, m.Products))
e.Lines.Add(new foodmarket.Domain.Sales.RetailSaleLine { OrganizationId = orgId, ProductId = pid, Quantity = a.Qty, UnitPrice = a.Qty != 0 ? decimal.Round(a.Amount / a.Qty, 4) : 0m, LineTotal = decimal.Round(a.Amount, 2) });
_db.RetailSales.Add(e);
if (posted) foreach (var l in e.Lines) await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, m.StoreId, -l.Quantity, MovementType.RetailSale, "retail-sale", e.Id, e.Number, l.UnitPrice, mom, m.AdminUserId), ct);
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, "retaildemand {Id}", d.Id); }
}
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)