Compare commits

...

2 commits

Author SHA1 Message Date
nns 417030827f docs(migration): enter 72/72 + retaildemand мигратор проверен (оплата верна), прогон 97k идёт
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:41:06 +05:00
nns 0c9d20a469 feat(migration): мигратор оприходований/списаний/возвратов поставщику/розничных продаж
По проверенному шаблону приёмок (идемпотентно по msId, резюмируемо, МСК→UTC, тиыны,
дизамбигуация номеров, SaveChanges по документу, движения остатков):
- enter (+остаток), loss (−остаток, WriteOff), purchasereturn (−остаток, SupplierReturn),
  retaildemand (−остаток, RetailSale; оплата нал/безнал из cashSum/noCashSum, кассир=owner).
- Общие помощники LoadMaps/Aggregate. Эндпоинт POST /import-supplies?type=enter|loss|
  purchasereturn|retaildemand (фон, job с нужным kind).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:34:36 +05:00
3 changed files with 175 additions and 6 deletions

View file

@ -27,9 +27,18 @@
fallback), 7385 проведённых с движениями остатков. Починены 5 багов: sum как decimal fallback), 7385 проведённых с движениями остатков. Починены 5 багов: sum как decimal
(«900000.0»), тяжёлый expand, дубль stocks при батче, каскад после сбоя SaveChanges, («900000.0»), тяжёлый expand, дубль stocks при батче, каскад после сбоя SaveChanges,
дубль номеров (МойСклад повторяет по годам → дизамбигуация «номер-год»). дубль номеров (МойСклад повторяет по годам → дизамбигуация «номер-год»).
- [ ] Остальные типы по ТОМУ ЖЕ шаблону: retaildemand (97k, +оплата нал/безнал, смена, - [x] **Оприходования (enter) — 72/72** перенесены и проверены (даты/суммы/статус).
кассир), retailsalesreturn (связь с чеком), loss, enter, inventory, supplier_returns, - [x] **Розничные продажи (retaildemand) — мигратор работает и ПРОВЕРЕН**: оплата
retailshift, внесения/выплаты/ордера/платежи. Объём большой (97k чеков ≈ 2.5ч прогона). нал/безнал из cashSum/noCashSum верна (сверено с МойСкладом: первые чеки noCashSum→Card),
даты МСК→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,8 +150,9 @@ 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 job = _jobs.Create("supplies"); var type = HttpContext.Request.Query["type"].ToString(); // захватываем ДО Task.Run (HttpContext потом недоступен)
job.Stage = "Перенос приёмок (с историей)…"; var job = _jobs.Create(string.IsNullOrEmpty(type) ? "supplies" : type);
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 { } },
@ -162,7 +163,14 @@ 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();
await docs.ImportSuppliesAsync(token, 0, pr, CancellationToken.None); switch (type)
{
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,6 +163,158 @@ 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)