diff --git a/src/food-market.api/Controllers/Admin/MoySkladImportController.cs b/src/food-market.api/Controllers/Admin/MoySkladImportController.cs index 0b25ee8..8ee19da 100644 --- a/src/food-market.api/Controllers/Admin/MoySkladImportController.cs +++ b/src/food-market.api/Controllers/Admin/MoySkladImportController.cs @@ -150,8 +150,9 @@ public async Task> ImportSupplies([FromBody] ImportRequest if (string.IsNullOrWhiteSpace(token)) return BadRequest(new { error = "Токен не настроен." }); var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); - var job = _jobs.Create("supplies"); - job.Stage = "Перенос приёмок (с историей)…"; + var type = HttpContext.Request.Query["type"].ToString(); // захватываем ДО Task.Run (HttpContext потом недоступен) + var job = _jobs.Create(string.IsNullOrEmpty(type) ? "supplies" : type); + job.Stage = $"Перенос документов ({(string.IsNullOrEmpty(type) ? "приёмки" : type)}) с историей…"; _ = Task.Run(async () => { using var flush = new Timer(_ => { try { using var s = HttpContextTenantContext.UseOverride(orgId); _ = _jobs.SaveAsync(job); } catch { } }, @@ -162,7 +163,14 @@ public async Task> ImportSupplies([FromBody] ImportRequest using var scope = _scopes.CreateScope(); var docs = scope.ServiceProvider.GetRequiredService(); 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.Message = $"Приёмки: перенесено {pr.Created}, пропущено {pr.Skipped}, ошибок {pr.Errors}. " + (pr.ErrorSamples.Count > 0 ? "Примеры: " + string.Join(" | ", pr.ErrorSamples.Take(5)) : ""); diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDocumentImportService.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDocumentImportService.cs index 393b0d1..4321a0c 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDocumentImportService.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDocumentImportService.cs @@ -163,6 +163,158 @@ string UniqueNumber(string baseName, int year) return progress; } + // ── общие помощники ────────────────────────────────────────────────────── + private static Dictionary Aggregate(MsDocument d, Dictionary products) + { + var byProduct = new Dictionary(); + 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 Products, Dictionary Counterparties, Guid? FallbackCp); + + private async Task 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 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(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 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(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 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(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 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(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; + } + /// Гарантирует склад орг с MoySkladExternalId = склад МойСклада (магазин /// с одним складом). Берём первый склад орг и помечаем его msId при первом вызове. private async Task EnsureStoreAsync(Guid orgId, CancellationToken ct)