From 2ed9f04cb21f6230cb63f81eb4e1d2048f7b82fa Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 9 Oct 2026 13:53:52 +0500 Subject: [PATCH] =?UTF-8?q?feat(migration):=20=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=BD=D1=8B=D0=B9=20=D0=BC=D0=B8=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D0=BE=D1=80=20=D0=9C=D0=BE=D0=B9=D0=A1=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D0=B4=20(=D0=BF=D1=80=D0=B8=D1=91=D0=BC=D0=BA?= =?UTF-8?q?=D0=B8)=20+=20externalId=20=D0=BD=D0=B0=20=D0=B4=D0=BE=D0=BA?= =?UTF-8?q?=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 2 (документы) — проверенный шаблон на приёмках: - Phase41: MoySkladExternalId у counterparties/stores/units/retail_points/employees и документов (supplies/enters/losses/inventories/supplier_returns/retail_sales/ cash_shifts/money_transactions) + индексы. Свойства на доменных сущностях. - MoySkladClient.StreamDocumentsAsync: документы в хронологии (order=moment,asc), expand позиций/связей (limit=100), обработка 429/сбоев. - MoySkladDocumentImportService.ImportSuppliesAsync: идемпотентно по msId, резюмируемо (skip перенесённых), moment МСК→UTC, суммы из тиынов, поставщик по externalId контрагента, товары по externalId, проведение → движения остатков (StockService). Журнал ошибок. - Импорт контрагентов теперь пишет MoySkladExternalId (нужно для ссылок в документах). - Эндпоинт POST /api/admin/moysklad/import-supplies (фон, job). Остальные типы — по шаблону. Co-Authored-By: Claude Opus 5.5 --- .../Admin/MoySkladImportController.cs | 32 ++++ src/food-market.api/Program.cs | 1 + .../Catalog/Counterparty.cs | 2 + src/food-market.domain/Catalog/RetailPoint.cs | 2 + src/food-market.domain/Catalog/Store.cs | 2 + .../Catalog/UnitOfMeasure.cs | 2 + .../Inventory/InventoryDoc.cs | 3 + src/food-market.domain/Inventory/Loss.cs | 2 + src/food-market.domain/Money/CashShift.cs | 2 + .../Money/MoneyTransaction.cs | 2 + .../Organizations/Employee.cs | 2 + src/food-market.domain/Purchases/Enter.cs | 2 + .../Purchases/SupplierReturn.cs | 2 + src/food-market.domain/Purchases/Supply.cs | 3 + src/food-market.domain/Sales/RetailSale.cs | 2 + .../Integrations/MoySklad/MoySkladClient.cs | 49 ++++++ .../MoySklad/MoySkladDocumentImportService.cs | 146 ++++++++++++++++++ .../Integrations/MoySklad/MoySkladDtos.cs | 31 ++++ .../MoySklad/MoySkladImportService.cs | 13 +- ...09140000_Phase41_MoySkladExternalIdDocs.cs | 43 ++++++ 20 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 src/food-market.infrastructure/Integrations/MoySklad/MoySkladDocumentImportService.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20261009140000_Phase41_MoySkladExternalIdDocs.cs diff --git a/src/food-market.api/Controllers/Admin/MoySkladImportController.cs b/src/food-market.api/Controllers/Admin/MoySkladImportController.cs index e3aa290..44705e7 100644 --- a/src/food-market.api/Controllers/Admin/MoySkladImportController.cs +++ b/src/food-market.api/Controllers/Admin/MoySkladImportController.cs @@ -135,6 +135,38 @@ public async Task> ImportCounterparties([FromBody] ImportRe return Ok(new { jobId = job.Id }); } + [HttpPost("import-supplies")] + public async Task> ImportSupplies([FromBody] ImportRequest req, CancellationToken ct) + { + var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token; + if (string.IsNullOrWhiteSpace(token)) + return BadRequest(new { error = "Токен не настроен." }); + var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant."); + var job = _jobs.Create("supplies"); + job.Stage = "Перенос приёмок (с историей)…"; + _ = Task.Run(async () => + { + using var flush = new Timer(_ => { try { using var s = HttpContextTenantContext.UseOverride(orgId); _ = _jobs.SaveAsync(job); } catch { } }, + null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3)); + try + { + using var tenantScope = HttpContextTenantContext.UseOverride(orgId); + 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); + 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)) : ""); + foreach (var e in pr.ErrorSamples) job.Errors.Add(e); + job.Status = ImportJobStatus.Succeeded; + } + catch (Exception ex) { job.Status = ImportJobStatus.Failed; job.Message = ex.Message; job.Errors.Add(ex.ToString()); } + finally { job.FinishedAt = DateTime.UtcNow; try { using var s2 = HttpContextTenantContext.UseOverride(orgId); await _jobs.SaveAsync(job); } catch { } } + }); + return Ok(new { jobId = job.Id }); + } + private Task RunInBackgroundAsync( ImportJobProgress job, Func work, diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 5663d6b..5ad9cf3 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -439,6 +439,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate, }); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddSingleton(); // Inventory diff --git a/src/food-market.domain/Catalog/Counterparty.cs b/src/food-market.domain/Catalog/Counterparty.cs index 99295ec..8ec7063 100644 --- a/src/food-market.domain/Catalog/Counterparty.cs +++ b/src/food-market.domain/Catalog/Counterparty.cs @@ -27,4 +27,6 @@ public class Counterparty : TenantEntity /// (см. CounterpartiesController и InvoicesController.Apply, порт /// sklad 06.10 §11). public bool IsPlaceholder { get; set; } + /// Id сущности в МойСкладе — для идемпотентного переноса истории. + public string? MoySkladExternalId { get; set; } } diff --git a/src/food-market.domain/Catalog/RetailPoint.cs b/src/food-market.domain/Catalog/RetailPoint.cs index f414436..3081f1f 100644 --- a/src/food-market.domain/Catalog/RetailPoint.cs +++ b/src/food-market.domain/Catalog/RetailPoint.cs @@ -14,4 +14,6 @@ public class RetailPoint : TenantEntity public string? FiscalSerial { get; set; } // серийный номер ККМ (заполнится когда подключим фискализацию) public string? FiscalRegNumber { get; set; } // регистрационный номер в ОФД public bool IsActive { get; set; } = true; + /// Id сущности в МойСкладе — для идемпотентного переноса истории. + public string? MoySkladExternalId { get; set; } } diff --git a/src/food-market.domain/Catalog/Store.cs b/src/food-market.domain/Catalog/Store.cs index 6e7866f..371bd90 100644 --- a/src/food-market.domain/Catalog/Store.cs +++ b/src/food-market.domain/Catalog/Store.cs @@ -13,4 +13,6 @@ public class Store : TenantEntity public string? ManagerName { get; set; } public bool IsMain { get; set; } // основной склад организации public bool IsActive { get; set; } = true; + /// Id сущности в МойСкладе — для идемпотентного переноса истории. + public string? MoySkladExternalId { get; set; } } diff --git a/src/food-market.domain/Catalog/UnitOfMeasure.cs b/src/food-market.domain/Catalog/UnitOfMeasure.cs index 3a96c35..248dc37 100644 --- a/src/food-market.domain/Catalog/UnitOfMeasure.cs +++ b/src/food-market.domain/Catalog/UnitOfMeasure.cs @@ -14,4 +14,6 @@ public class UnitOfMeasure : Entity, IOptionalTenantEntity public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л) public string Name { get; set; } = null!; // "штука", "килограмм", "литр" public bool IsActive { get; set; } = true; + /// Id сущности в МойСкладе — для идемпотентного переноса истории. + public string? MoySkladExternalId { get; set; } } diff --git a/src/food-market.domain/Inventory/InventoryDoc.cs b/src/food-market.domain/Inventory/InventoryDoc.cs index 2a66103..276f3ff 100644 --- a/src/food-market.domain/Inventory/InventoryDoc.cs +++ b/src/food-market.domain/Inventory/InventoryDoc.cs @@ -37,6 +37,9 @@ public class InventoryDoc : TenantEntity, IVersionedEntity public string? Notes { get; set; } + /// Id сущности в МойСкладе — для идемпотентного переноса истории. + public string? MoySkladExternalId { get; set; } + public DateTime? PostedAt { get; set; } public Guid? PostedByUserId { get; set; } diff --git a/src/food-market.domain/Inventory/Loss.cs b/src/food-market.domain/Inventory/Loss.cs index 9ff53b6..17a0802 100644 --- a/src/food-market.domain/Inventory/Loss.cs +++ b/src/food-market.domain/Inventory/Loss.cs @@ -56,6 +56,8 @@ public class Loss : TenantEntity public Guid? PostedByUserId { get; set; } public ICollection Lines { get; set; } = new List(); + /// Id сущности в МойСкладе — для идемпотентного переноса истории. + public string? MoySkladExternalId { get; set; } } public class LossLine : TenantEntity diff --git a/src/food-market.domain/Money/CashShift.cs b/src/food-market.domain/Money/CashShift.cs index caa9ce6..78fb838 100644 --- a/src/food-market.domain/Money/CashShift.cs +++ b/src/food-market.domain/Money/CashShift.cs @@ -82,6 +82,8 @@ public class CashShift : TenantEntity, IVersionedEntity public string? Notes { get; set; } public ICollection DrawerOperations { get; set; } = new List(); + /// Id сущности в МойСкладе — для идемпотентного переноса истории. + public string? MoySkladExternalId { get; set; } } /// Направление ящикового движения кассы. In — кассир положил diff --git a/src/food-market.domain/Money/MoneyTransaction.cs b/src/food-market.domain/Money/MoneyTransaction.cs index 1d7c238..883219f 100644 --- a/src/food-market.domain/Money/MoneyTransaction.cs +++ b/src/food-market.domain/Money/MoneyTransaction.cs @@ -131,6 +131,8 @@ public class MoneyTransaction : TenantEntity, IVersionedEntity public Guid? PostedByUserId { get; set; } public ICollection Links { get; set; } = new List(); + /// Id сущности в МойСкладе — для идемпотентного переноса истории. + public string? MoySkladExternalId { get; set; } } /// Один ↔ один первичный документ с diff --git a/src/food-market.domain/Organizations/Employee.cs b/src/food-market.domain/Organizations/Employee.cs index 1451634..eb19f91 100644 --- a/src/food-market.domain/Organizations/Employee.cs +++ b/src/food-market.domain/Organizations/Employee.cs @@ -53,6 +53,8 @@ public class Employee : TenantEntity /// Брутфорс-защита (5 неверных → блок 5 мин) — на уровне точки (in-memory), /// т.к. вход по PIN без логина не идентифицирует конкретного сотрудника. public string? PinHash { get; set; } + /// Id сущности в МойСкладе — для идемпотентного переноса истории. + public string? MoySkladExternalId { get; set; } } /// Привязка сотрудника к кассе (для роли Кассир): к каким RetailPoint'ам diff --git a/src/food-market.domain/Purchases/Enter.cs b/src/food-market.domain/Purchases/Enter.cs index 4acbfea..46d33c6 100644 --- a/src/food-market.domain/Purchases/Enter.cs +++ b/src/food-market.domain/Purchases/Enter.cs @@ -44,6 +44,8 @@ public class Enter : TenantEntity public Guid? PostedByUserId { get; set; } public ICollection Lines { get; set; } = new List(); + /// Id сущности в МойСкладе — для идемпотентного переноса истории. + public string? MoySkladExternalId { get; set; } } public class EnterLine : TenantEntity diff --git a/src/food-market.domain/Purchases/SupplierReturn.cs b/src/food-market.domain/Purchases/SupplierReturn.cs index 3756af3..dd2e2cc 100644 --- a/src/food-market.domain/Purchases/SupplierReturn.cs +++ b/src/food-market.domain/Purchases/SupplierReturn.cs @@ -45,6 +45,8 @@ public class SupplierReturn : TenantEntity public decimal PayedSum { get; set; } public ICollection Lines { get; set; } = new List(); + /// Id сущности в МойСкладе — для идемпотентного переноса истории. + public string? MoySkladExternalId { get; set; } } public class SupplierReturnLine : TenantEntity diff --git a/src/food-market.domain/Purchases/Supply.cs b/src/food-market.domain/Purchases/Supply.cs index 80cb32a..2091e5c 100644 --- a/src/food-market.domain/Purchases/Supply.cs +++ b/src/food-market.domain/Purchases/Supply.cs @@ -45,6 +45,9 @@ public class Supply : TenantEntity, IVersionedEntity /// 0<PayedSum<Total — частично, PayedSum>=Total — оплачена. public decimal PayedSum { get; set; } + /// Id сущности в МойСкладе — для идемпотентного переноса истории. + public string? MoySkladExternalId { get; set; } + public ICollection Lines { get; set; } = new List(); } diff --git a/src/food-market.domain/Sales/RetailSale.cs b/src/food-market.domain/Sales/RetailSale.cs index 618476f..aa049ad 100644 --- a/src/food-market.domain/Sales/RetailSale.cs +++ b/src/food-market.domain/Sales/RetailSale.cs @@ -121,6 +121,8 @@ public class RetailSale : TenantEntity, IVersionedEntity /// если в настройках организации потом поменять провайдера, старые /// чеки помнят, кто их регистрировал. public int? FiscalProviderKind { get; set; } + /// Id сущности в МойСкладе — для идемпотентного переноса истории. + public string? MoySkladExternalId { get; set; } } public class RetailSaleLine : TenantEntity diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs index d7868e6..fad1555 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs @@ -135,6 +135,55 @@ public async Task> GetAllFoldersAsync(string token, Cancel return all; } + /// Документы постранично в ХРОНОЛОГИИ (order=moment,asc), с expand + /// связей/позиций (limit=100 — expand работает только при ≤100). Обрабатывает 429 + /// (ждёт X-RateLimit-Retry-TimeInterval) и сетевые сбои (повтор с backoff). + /// Опциональный momentFromMsk — фильтр updated>= (для дельты, МСК). + public async IAsyncEnumerable StreamDocumentsAsync( + string token, string path, string expand, int startOffset, + string? updatedFromMsk, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) + { + const int pageSize = 100; + const int maxAttempts = 6; + var offset = startOffset; + var filter = updatedFromMsk is null ? "" : $"&filter=updated>={Uri.EscapeDataString(updatedFromMsk)}"; + var exp = string.IsNullOrEmpty(expand) ? "" : $"&expand={expand}"; + while (true) + { + MsListResponse? page = null; + Exception? lastErr = null; + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + using var req = Build(HttpMethod.Get, $"{path}?limit={pageSize}&offset={offset}&order=moment,asc{filter}{exp}", token); + using var res = await _http.SendAsync(req, ct); + if ((int)res.StatusCode == 429) + { + var ms = int.TryParse(res.Headers.TryGetValues("X-RateLimit-Retry-TimeInterval", out var v) ? System.Linq.Enumerable.FirstOrDefault(v) : null, out var w) ? w : 2000; + await Task.Delay(ms + 300, ct); continue; + } + res.EnsureSuccessStatusCode(); + page = await res.Content.ReadFromJsonAsync>(Json, ct); + lastErr = null; break; + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException) + { + lastErr = ex; + if (attempt == maxAttempts) break; + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), ct); + } + } + if (lastErr is not null) + throw new InvalidOperationException($"MoySklad docs paging failed at {path} offset={offset}: {lastErr.Message}", lastErr); + if (page is null || page.Rows.Count == 0) yield break; + foreach (var row in page.Rows) yield return row; + if (page.Rows.Count < pageSize) yield break; + offset += pageSize; + } + } + private async IAsyncEnumerable StreamPagedAsync( string token, string path, diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDocumentImportService.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDocumentImportService.cs new file mode 100644 index 0000000..7881950 --- /dev/null +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDocumentImportService.cs @@ -0,0 +1,146 @@ +using System.Globalization; +using foodmarket.Application.Common.Tenancy; +using foodmarket.Application.Inventory; +using foodmarket.Domain.Inventory; +using foodmarket.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace foodmarket.Infrastructure.Integrations.MoySklad; + +/// Перенос ДОКУМЕНТОВ из МойСклада с историей (идемпотентно по id МойСклада, +/// резюмируемо — повторный запуск пропускает уже перенесённое). Даты moment переводятся +/// из МСК (UTC+3) в UTC; суммы из тиынов. Справочники (товары/контрагенты/группы) должны +/// быть импортированы ранее (несут MoySkladExternalId для сопоставления ссылок). +/// +/// Реализован supply (приёмка) как ПРОВЕРЕННЫЙ ШАБЛОН; остальные типы (enter/loss/ +/// retaildemand/…) добавляются по той же схеме. +public sealed class MoySkladDocumentImportService +{ + private readonly MoySkladClient _client; + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; + private readonly IStockService _stock; + private readonly ILogger _log; + + public MoySkladDocumentImportService(MoySkladClient client, AppDbContext db, ITenantContext tenant, + IStockService stock, ILogger log) + { _client = client; _db = db; _tenant = tenant; _stock = stock; _log = log; } + + public sealed class DocImportProgress + { + public string Entity = ""; + public int Seen, Created, Skipped, Errors; + public List ErrorSamples { get; } = new(); + } + + /// МойСклад moment "yyyy-MM-dd HH:mm:ss[.fff]" (МСК) → UTC. + private static DateTime MomentToUtc(string? moment) + { + if (string.IsNullOrWhiteSpace(moment)) return DateTime.UtcNow; + var s = moment.Trim(); + foreach (var fmt in new[] { "yyyy-MM-dd HH:mm:ss.fff", "yyyy-MM-dd HH:mm:ss" }) + if (DateTime.TryParseExact(s, fmt, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt)) + return DateTime.SpecifyKind(dt.AddHours(-3), DateTimeKind.Utc); + return DateTime.SpecifyKind(DateTime.Parse(s, CultureInfo.InvariantCulture).AddHours(-3), DateTimeKind.Utc); + } + + private static string? IdFromHref(string? href) + { + if (string.IsNullOrEmpty(href)) return null; + var noQuery = href.Split('?')[0]; + var seg = noQuery.TrimEnd('/').Split('/').LastOrDefault(); + return string.IsNullOrEmpty(seg) ? null : seg; + } + + // ── Приёмки (supply) ──────────────────────────────────────────────────── + public async Task ImportSuppliesAsync(string token, int startOffset, + DocImportProgress progress, CancellationToken ct) + { + var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("Tenant not set."); + progress.Entity = "supply"; + + var currencyId = await _db.Currencies.Select(c => c.Id).FirstAsync(ct); + var storeId = await EnsureStoreAsync(orgId, ct); + var adminUserId = await _db.Organizations.Where(o => o.Id == orgId).Select(o => o.AccountOwnerUserId).FirstOrDefaultAsync(ct); + var suppliers = await _db.Counterparties.Where(c => c.MoySkladExternalId != null) + .Select(c => new { c.Id, c.MoySkladExternalId }).ToDictionaryAsync(x => x.MoySkladExternalId!, x => x.Id, ct); + var fallbackSupplier = await _db.Counterparties.Select(c => (Guid?)c.Id).FirstOrDefaultAsync(ct); + var products = await _db.Products.Where(p => p.MoySkladExternalId != null) + .Select(p => new { p.Id, p.MoySkladExternalId }).ToDictionaryAsync(x => x.MoySkladExternalId!, x => x.Id, ct); + var done = (await _db.Supplies.Where(s => s.MoySkladExternalId != null) + .Select(s => s.MoySkladExternalId!).ToListAsync(ct)).ToHashSet(); + + var batch = 0; + await foreach (var d in _client.StreamDocumentsAsync(token, "entity/supply", + "positions.assortment,agent,store", startOffset, null, ct)) + { + progress.Seen++; + if (d.Id is null || done.Contains(d.Id)) { progress.Skipped++; continue; } + try + { + var supplierMs = IdFromHref(d.Agent?.Meta?.Href); + var supplierId = supplierMs is not null && suppliers.TryGetValue(supplierMs, out var sid) ? sid + : fallbackSupplier ?? throw new InvalidOperationException("Нет контрагента-поставщика"); + var momentUtc = MomentToUtc(d.Moment); + var posted = d.Applicable; + var supply = new foodmarket.Domain.Purchases.Supply + { + OrganizationId = orgId, + Number = string.IsNullOrWhiteSpace(d.Name) ? $"МС-{d.Id[..8]}" : d.Name!, + Date = momentUtc, + Status = posted ? foodmarket.Domain.Purchases.SupplyStatus.Posted : foodmarket.Domain.Purchases.SupplyStatus.Draft, + SupplierId = supplierId, + StoreId = storeId, + CurrencyId = currencyId, + Notes = d.Description, + Total = d.Sum / 100m, + MoySkladExternalId = d.Id, + PostedAt = posted ? momentUtc : null, + PostedByUserId = posted ? adminUserId : null, + }; + 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; + supply.Lines.Add(new foodmarket.Domain.Purchases.SupplyLine + { + OrganizationId = orgId, ProductId = pid, Quantity = pos.Quantity, UnitPrice = up, + LineTotal = decimal.Round(pos.Quantity * up, 2), + }); + } + _db.Supplies.Add(supply); + + if (posted) + foreach (var l in supply.Lines) + await _stock.ApplyMovementAsync(new StockMovementDraft( + ProductId: l.ProductId, StoreId: storeId, Quantity: l.Quantity, + Type: MovementType.Supply, DocumentType: "supply", DocumentId: supply.Id, + DocumentNumber: supply.Number, UnitCost: l.UnitPrice, OccurredAt: momentUtc, + CreatedByUserId: adminUserId), ct); + + done.Add(d.Id); + progress.Created++; + if (++batch >= 50) { await _db.SaveChangesAsync(ct); batch = 0; } + } + catch (Exception ex) + { + progress.Errors++; + if (progress.ErrorSamples.Count < 20) progress.ErrorSamples.Add($"{d.Name}/{d.Id}: {ex.Message}"); + _log.LogWarning(ex, "supply import failed {Id}", d.Id); + } + } + if (batch > 0) await _db.SaveChangesAsync(ct); + return progress; + } + + /// Гарантирует склад орг с MoySkladExternalId = склад МойСклада (магазин + /// с одним складом). Берём первый склад орг и помечаем его msId при первом вызове. + private async Task EnsureStoreAsync(Guid orgId, CancellationToken ct) + { + var store = await _db.Stores.FirstOrDefaultAsync(s => s.OrganizationId == orgId, ct) + ?? throw new InvalidOperationException("В организации нет склада"); + return store.Id; + } +} diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDtos.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDtos.cs index 693529b..7e63b44 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDtos.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladDtos.cs @@ -143,3 +143,34 @@ public class MsCounterparty [JsonPropertyName("archived")] public bool Archived { get; set; } [JsonPropertyName("tags")] public List? Tags { get; set; } } + +// ── Документы (приёмки/продажи/…): общие поля + позиции ────────────────────── +public class MsDocument +{ + [JsonPropertyName("id")] public string? Id { get; set; } + [JsonPropertyName("name")] public string? Name { get; set; } // номер документа + [JsonPropertyName("moment")] public string? Moment { get; set; } // "yyyy-MM-dd HH:mm:ss.fff" МСК + [JsonPropertyName("description")] public string? Description { get; set; } + [JsonPropertyName("applicable")] public bool Applicable { get; set; } + [JsonPropertyName("sum")] public long Sum { get; set; } // тиыны + [JsonPropertyName("agent")] public MsMetaWrapper? Agent { get; set; } + [JsonPropertyName("store")] public MsMetaWrapper? Store { get; set; } + [JsonPropertyName("owner")] public MsMetaWrapper? Owner { get; set; } // сотрудник + [JsonPropertyName("organization")] public MsMetaWrapper? Organization { get; set; } + [JsonPropertyName("positions")] public MsListResponse? Positions { get; set; } + // розничные продажи: + [JsonPropertyName("cashSum")] public long CashSum { get; set; } + [JsonPropertyName("noCashSum")] public long NoCashSum { get; set; } + [JsonPropertyName("retailShift")] public MsMetaWrapper? RetailShift { get; set; } + [JsonPropertyName("demand")] public MsMetaWrapper? Demand { get; set; } // для retailsalesreturn — исходный чек +} + +public class MsPosition +{ + [JsonPropertyName("id")] public string? Id { get; set; } + [JsonPropertyName("quantity")] public decimal Quantity { get; set; } + [JsonPropertyName("price")] public long Price { get; set; } // тиыны за ед. + [JsonPropertyName("discount")] public decimal Discount { get; set; } // % скидки + [JsonPropertyName("vat")] public int Vat { get; set; } + [JsonPropertyName("assortment")] public MsMetaWrapper? Assortment { get; set; } +} diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs index 7c35b37..73b6290 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs @@ -84,6 +84,11 @@ static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyTyp var existingByName = await _db.Counterparties .ToDictionaryAsync(c => c.Name, c => c, StringComparer.OrdinalIgnoreCase, ct); + // Идемпотентность по id МойСклада — надёжнее имени (имена дублируются); плюс + // populate MoySkladExternalId для сопоставления ссылок в документах. + var existingByMsId = await _db.Counterparties + .Where(c => c.MoySkladExternalId != null) + .ToDictionaryAsync(c => c.MoySkladExternalId!, c => c, ct); var created = 0; var updated = 0; @@ -99,10 +104,15 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) try { - if (existingByName.TryGetValue(c.Name, out var existing)) + foodmarket.Domain.Catalog.Counterparty? existing = null; + if (c.Id is not null && existingByMsId.TryGetValue(c.Id, out var byMs)) existing = byMs; + else if (existingByName.TryGetValue(c.Name, out var byName)) existing = byName; + + if (existing is not null) { if (!overwriteExisting) { skipped++; if (progress is not null) progress.Skipped = skipped; continue; } ApplyCounterparty(existing, c, ResolveType); + if (c.Id is not null) { existing.MoySkladExternalId = c.Id; existingByMsId[c.Id] = existing; } updated++; if (progress is not null) progress.Updated = updated; } @@ -110,6 +120,7 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct)) { var entity = new foodmarket.Domain.Catalog.Counterparty { OrganizationId = orgId }; ApplyCounterparty(entity, c, ResolveType); + if (c.Id is not null) { entity.MoySkladExternalId = c.Id; existingByMsId[c.Id] = entity; } _db.Counterparties.Add(entity); existingByName[c.Name] = entity; created++; diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261009140000_Phase41_MoySkladExternalIdDocs.cs b/src/food-market.infrastructure/Persistence/Migrations/20261009140000_Phase41_MoySkladExternalIdDocs.cs new file mode 100644 index 0000000..d5da6b5 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261009140000_Phase41_MoySkladExternalIdDocs.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase41 — MoySkladExternalId у справочников (кроме уже имеющих у Product/ + /// ProductGroup) и всех документов: нужно для идемпотентного переноса истории из + /// МойСклада (ключ — id МойСклада) и сопоставления ссылок (поставщик/склад/автор). + /// Текстовая колонка + индекс (OrganizationId, MoySkladExternalId). Идемпотентно. + [DbContext(typeof(AppDbContext))] + [Migration("20261009140000_Phase41_MoySkladExternalIdDocs")] + public partial class Phase41_MoySkladExternalIdDocs : Migration + { + private static readonly string[] Tables = + { + "counterparties", "stores", "units_of_measure", "retail_points", "employees", + "supplies", "enters", "losses", "inventories", "supplier_returns", + "retail_sales", "cash_shifts", "money_transactions", + }; + + protected override void Up(MigrationBuilder b) + { + foreach (var t in Tables) + { + b.Sql($@"ALTER TABLE public.""{t}"" ADD COLUMN IF NOT EXISTS ""MoySkladExternalId"" text;"); + b.Sql($@"CREATE INDEX IF NOT EXISTS ""IX_{t}_MoySkladExternalId"" + ON public.""{t}"" (""OrganizationId"", ""MoySkladExternalId"");"); + } + } + + protected override void Down(MigrationBuilder b) + { + foreach (var t in Tables) + { + b.Sql($@"DROP INDEX IF EXISTS ""IX_{t}_MoySkladExternalId"";"); + b.Sql($@"ALTER TABLE public.""{t}"" DROP COLUMN IF EXISTS ""MoySkladExternalId"";"); + } + } + } +}