feat(migration): документный мигратор МойСклад (приёмки) + externalId на документах
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 <noreply@anthropic.com>
This commit is contained in:
parent
4667d8e1a7
commit
2ed9f04cb2
|
|
@ -135,6 +135,38 @@ public async Task<ActionResult<object>> ImportCounterparties([FromBody] ImportRe
|
||||||
return Ok(new { jobId = job.Id });
|
return Ok(new { jobId = job.Id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("import-supplies")]
|
||||||
|
public async Task<ActionResult<object>> 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<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladDocumentImportService>();
|
||||||
|
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(
|
private Task RunInBackgroundAsync(
|
||||||
ImportJobProgress job,
|
ImportJobProgress job,
|
||||||
Func<MoySkladImportService, ImportJobProgress, CancellationToken, Task> work,
|
Func<MoySkladImportService, ImportJobProgress, CancellationToken, Task> work,
|
||||||
|
|
|
||||||
|
|
@ -439,6 +439,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
|
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
|
||||||
});
|
});
|
||||||
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
|
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
|
||||||
|
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladDocumentImportService>();
|
||||||
builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();
|
builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();
|
||||||
|
|
||||||
// Inventory
|
// Inventory
|
||||||
|
|
|
||||||
|
|
@ -27,4 +27,6 @@ public class Counterparty : TenantEntity
|
||||||
/// (см. CounterpartiesController и InvoicesController.Apply, порт
|
/// (см. CounterpartiesController и InvoicesController.Apply, порт
|
||||||
/// sklad 06.10 §11).</summary>
|
/// sklad 06.10 §11).</summary>
|
||||||
public bool IsPlaceholder { get; set; }
|
public bool IsPlaceholder { get; set; }
|
||||||
|
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||||
|
public string? MoySkladExternalId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,6 @@ public class RetailPoint : TenantEntity
|
||||||
public string? FiscalSerial { get; set; } // серийный номер ККМ (заполнится когда подключим фискализацию)
|
public string? FiscalSerial { get; set; } // серийный номер ККМ (заполнится когда подключим фискализацию)
|
||||||
public string? FiscalRegNumber { get; set; } // регистрационный номер в ОФД
|
public string? FiscalRegNumber { get; set; } // регистрационный номер в ОФД
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||||
|
public string? MoySkladExternalId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,6 @@ public class Store : TenantEntity
|
||||||
public string? ManagerName { get; set; }
|
public string? ManagerName { get; set; }
|
||||||
public bool IsMain { get; set; } // основной склад организации
|
public bool IsMain { get; set; } // основной склад организации
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||||
|
public string? MoySkladExternalId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,6 @@ public class UnitOfMeasure : Entity, IOptionalTenantEntity
|
||||||
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
|
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
|
||||||
public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
|
public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||||
|
public string? MoySkladExternalId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@ public class InventoryDoc : TenantEntity, IVersionedEntity
|
||||||
|
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||||
|
public string? MoySkladExternalId { get; set; }
|
||||||
|
|
||||||
public DateTime? PostedAt { get; set; }
|
public DateTime? PostedAt { get; set; }
|
||||||
public Guid? PostedByUserId { get; set; }
|
public Guid? PostedByUserId { get; set; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@ public class Loss : TenantEntity
|
||||||
public Guid? PostedByUserId { get; set; }
|
public Guid? PostedByUserId { get; set; }
|
||||||
|
|
||||||
public ICollection<LossLine> Lines { get; set; } = new List<LossLine>();
|
public ICollection<LossLine> Lines { get; set; } = new List<LossLine>();
|
||||||
|
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||||
|
public string? MoySkladExternalId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class LossLine : TenantEntity
|
public class LossLine : TenantEntity
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,8 @@ public class CashShift : TenantEntity, IVersionedEntity
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
public ICollection<CashDrawerOperation> DrawerOperations { get; set; } = new List<CashDrawerOperation>();
|
public ICollection<CashDrawerOperation> DrawerOperations { get; set; } = new List<CashDrawerOperation>();
|
||||||
|
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||||
|
public string? MoySkladExternalId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Направление ящикового движения кассы. In — кассир положил
|
/// <summary>Направление ящикового движения кассы. In — кассир положил
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,8 @@ public class MoneyTransaction : TenantEntity, IVersionedEntity
|
||||||
public Guid? PostedByUserId { get; set; }
|
public Guid? PostedByUserId { get; set; }
|
||||||
|
|
||||||
public ICollection<MoneyTransactionLink> Links { get; set; } = new List<MoneyTransactionLink>();
|
public ICollection<MoneyTransactionLink> Links { get; set; } = new List<MoneyTransactionLink>();
|
||||||
|
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||||
|
public string? MoySkladExternalId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Один <see cref="MoneyTransaction"/> ↔ один первичный документ с
|
/// <summary>Один <see cref="MoneyTransaction"/> ↔ один первичный документ с
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ public class Employee : TenantEntity
|
||||||
/// Брутфорс-защита (5 неверных → блок 5 мин) — на уровне точки (in-memory),
|
/// Брутфорс-защита (5 неверных → блок 5 мин) — на уровне точки (in-memory),
|
||||||
/// т.к. вход по PIN без логина не идентифицирует конкретного сотрудника.</summary>
|
/// т.к. вход по PIN без логина не идентифицирует конкретного сотрудника.</summary>
|
||||||
public string? PinHash { get; set; }
|
public string? PinHash { get; set; }
|
||||||
|
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||||
|
public string? MoySkladExternalId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Привязка сотрудника к кассе (для роли Кассир): к каким RetailPoint'ам
|
/// <summary>Привязка сотрудника к кассе (для роли Кассир): к каким RetailPoint'ам
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ public class Enter : TenantEntity
|
||||||
public Guid? PostedByUserId { get; set; }
|
public Guid? PostedByUserId { get; set; }
|
||||||
|
|
||||||
public ICollection<EnterLine> Lines { get; set; } = new List<EnterLine>();
|
public ICollection<EnterLine> Lines { get; set; } = new List<EnterLine>();
|
||||||
|
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||||
|
public string? MoySkladExternalId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class EnterLine : TenantEntity
|
public class EnterLine : TenantEntity
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ public class SupplierReturn : TenantEntity
|
||||||
public decimal PayedSum { get; set; }
|
public decimal PayedSum { get; set; }
|
||||||
|
|
||||||
public ICollection<SupplierReturnLine> Lines { get; set; } = new List<SupplierReturnLine>();
|
public ICollection<SupplierReturnLine> Lines { get; set; } = new List<SupplierReturnLine>();
|
||||||
|
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||||
|
public string? MoySkladExternalId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SupplierReturnLine : TenantEntity
|
public class SupplierReturnLine : TenantEntity
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ public class Supply : TenantEntity, IVersionedEntity
|
||||||
/// 0<PayedSum<Total — частично, PayedSum>=Total — оплачена.</summary>
|
/// 0<PayedSum<Total — частично, PayedSum>=Total — оплачена.</summary>
|
||||||
public decimal PayedSum { get; set; }
|
public decimal PayedSum { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||||
|
public string? MoySkladExternalId { get; set; }
|
||||||
|
|
||||||
public ICollection<SupplyLine> Lines { get; set; } = new List<SupplyLine>();
|
public ICollection<SupplyLine> Lines { get; set; } = new List<SupplyLine>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,8 @@ public class RetailSale : TenantEntity, IVersionedEntity
|
||||||
/// если в настройках организации потом поменять провайдера, старые
|
/// если в настройках организации потом поменять провайдера, старые
|
||||||
/// чеки помнят, кто их регистрировал.</summary>
|
/// чеки помнят, кто их регистрировал.</summary>
|
||||||
public int? FiscalProviderKind { get; set; }
|
public int? FiscalProviderKind { get; set; }
|
||||||
|
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||||
|
public string? MoySkladExternalId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class RetailSaleLine : TenantEntity
|
public class RetailSaleLine : TenantEntity
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,55 @@ public async Task<List<MsProductFolder>> GetAllFoldersAsync(string token, Cancel
|
||||||
return all;
|
return all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Документы постранично в ХРОНОЛОГИИ (order=moment,asc), с expand
|
||||||
|
/// связей/позиций (limit=100 — expand работает только при ≤100). Обрабатывает 429
|
||||||
|
/// (ждёт X-RateLimit-Retry-TimeInterval) и сетевые сбои (повтор с backoff).
|
||||||
|
/// Опциональный momentFromMsk — фильтр updated>= (для дельты, МСК).</summary>
|
||||||
|
public async IAsyncEnumerable<T> StreamDocumentsAsync<T>(
|
||||||
|
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<T>? 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<MsListResponse<T>>(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<T> StreamPagedAsync<T>(
|
private async IAsyncEnumerable<T> StreamPagedAsync<T>(
|
||||||
string token,
|
string token,
|
||||||
string path,
|
string path,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>Перенос ДОКУМЕНТОВ из МойСклада с историей (идемпотентно по id МойСклада,
|
||||||
|
/// резюмируемо — повторный запуск пропускает уже перенесённое). Даты moment переводятся
|
||||||
|
/// из МСК (UTC+3) в UTC; суммы из тиынов. Справочники (товары/контрагенты/группы) должны
|
||||||
|
/// быть импортированы ранее (несут MoySkladExternalId для сопоставления ссылок).
|
||||||
|
///
|
||||||
|
/// Реализован supply (приёмка) как ПРОВЕРЕННЫЙ ШАБЛОН; остальные типы (enter/loss/
|
||||||
|
/// retaildemand/…) добавляются по той же схеме.</summary>
|
||||||
|
public sealed class MoySkladDocumentImportService
|
||||||
|
{
|
||||||
|
private readonly MoySkladClient _client;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ITenantContext _tenant;
|
||||||
|
private readonly IStockService _stock;
|
||||||
|
private readonly ILogger<MoySkladDocumentImportService> _log;
|
||||||
|
|
||||||
|
public MoySkladDocumentImportService(MoySkladClient client, AppDbContext db, ITenantContext tenant,
|
||||||
|
IStockService stock, ILogger<MoySkladDocumentImportService> 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<string> ErrorSamples { get; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>МойСклад moment "yyyy-MM-dd HH:mm:ss[.fff]" (МСК) → UTC.</summary>
|
||||||
|
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<DocImportProgress> 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<MsDocument>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Гарантирует склад орг с MoySkladExternalId = склад МойСклада (магазин
|
||||||
|
/// с одним складом). Берём первый склад орг и помечаем его msId при первом вызове.</summary>
|
||||||
|
private async Task<Guid> EnsureStoreAsync(Guid orgId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var store = await _db.Stores.FirstOrDefaultAsync(s => s.OrganizationId == orgId, ct)
|
||||||
|
?? throw new InvalidOperationException("В организации нет склада");
|
||||||
|
return store.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -143,3 +143,34 @@ public class MsCounterparty
|
||||||
[JsonPropertyName("archived")] public bool Archived { get; set; }
|
[JsonPropertyName("archived")] public bool Archived { get; set; }
|
||||||
[JsonPropertyName("tags")] public List<string>? Tags { get; set; }
|
[JsonPropertyName("tags")] public List<string>? 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<MsPosition>? 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; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,11 @@ static foodmarket.Domain.Catalog.CounterpartyType ResolveType(string? companyTyp
|
||||||
|
|
||||||
var existingByName = await _db.Counterparties
|
var existingByName = await _db.Counterparties
|
||||||
.ToDictionaryAsync(c => c.Name, c => c, StringComparer.OrdinalIgnoreCase, ct);
|
.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 created = 0;
|
||||||
var updated = 0;
|
var updated = 0;
|
||||||
|
|
@ -99,10 +104,15 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
||||||
|
|
||||||
try
|
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; }
|
if (!overwriteExisting) { skipped++; if (progress is not null) progress.Skipped = skipped; continue; }
|
||||||
ApplyCounterparty(existing, c, ResolveType);
|
ApplyCounterparty(existing, c, ResolveType);
|
||||||
|
if (c.Id is not null) { existing.MoySkladExternalId = c.Id; existingByMsId[c.Id] = existing; }
|
||||||
updated++;
|
updated++;
|
||||||
if (progress is not null) progress.Updated = 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 };
|
var entity = new foodmarket.Domain.Catalog.Counterparty { OrganizationId = orgId };
|
||||||
ApplyCounterparty(entity, c, ResolveType);
|
ApplyCounterparty(entity, c, ResolveType);
|
||||||
|
if (c.Id is not null) { entity.MoySkladExternalId = c.Id; existingByMsId[c.Id] = entity; }
|
||||||
_db.Counterparties.Add(entity);
|
_db.Counterparties.Add(entity);
|
||||||
existingByName[c.Name] = entity;
|
existingByName[c.Name] = entity;
|
||||||
created++;
|
created++;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>Phase41 — MoySkladExternalId у справочников (кроме уже имеющих у Product/
|
||||||
|
/// ProductGroup) и всех документов: нужно для идемпотентного переноса истории из
|
||||||
|
/// МойСклада (ключ — id МойСклада) и сопоставления ссылок (поставщик/склад/автор).
|
||||||
|
/// Текстовая колонка + индекс (OrganizationId, MoySkladExternalId). Идемпотентно.</summary>
|
||||||
|
[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"";");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue