Compare commits
No commits in common. "d3b1a7d3df17b9f0b16df54fefa211929a060717" and "4667d8e1a777d0bc711c4147e3987d7ee56a3ac8" have entirely different histories.
d3b1a7d3df
...
4667d8e1a7
|
|
@ -14,20 +14,13 @@
|
|||
1. [x] **Инвентаризация источника** → `docs/migration-source-inventory.md` (скрипт
|
||||
`scripts/migration/ms_inventory.py`). Орг «FOOD MARKET», ~31k товаров, 627 контрагентов,
|
||||
~110k документов, 7558 строк остатков; gap-анализ + цели сверки.
|
||||
2. [~] **Мигратор — работает на реальных данных.**
|
||||
- [x] Справочники: товары **30 944**, контрагенты **628** (+externalId), группы **53**;
|
||||
идемпотентность по `MoySkladExternalId`.
|
||||
- [x] **Документный мигратор (сервер, фон, идемпотентный, резюмируемый)** — архитектура
|
||||
проверена на ПРИЁМКАХ: `MoySkladDocumentImportService` + `StreamDocumentsAsync`
|
||||
(хронология order=moment,asc, expand позиций, 429/повторы, таймаут 130с),
|
||||
`Phase41` externalId на всех документах/справочниках. Приёмки переносятся ~10/с с
|
||||
движениями остатков; moment МСК→UTC, суммы из тиынов, поставщик по externalId,
|
||||
товары по externalId, агрегация позиций по товару, SaveChanges по документу.
|
||||
Починены 3 бага: sum как decimal («900000.0»), тяжёлый expand, дубль stocks при батче.
|
||||
- [ ] Остальные типы (retaildemand/loss/enter/inventory/supplier_returns/shifts/деньги) —
|
||||
по ТОМУ ЖЕ проверенному шаблону (следующая итерация).
|
||||
- [ ] uom/склад/точка/сотрудники как отдельные справочники с externalId (пока товары на
|
||||
дефолтных ед.; склад — единственный склад орг).
|
||||
2. [~] **Мигратор.** Существующий `MoySkladImportService` (справочники product/productfolder/
|
||||
counterparty, идемпотентность по `MoySkladExternalId`) — РАБОТАЕТ на реальных данных:
|
||||
- [x] Контрагенты → **628** в новой орг (МС 627 + 1 розн. покупатель по умолчанию).
|
||||
- [x] Группы → **53** (МС 52 + корень). [~] Товары — импорт запущен (31k, фон, ~3600/20с).
|
||||
- [ ] uom/склад/точка/сотрудники как отдельные справочники (сейчас товары мапятся на
|
||||
дефолтные ед.); externalId на Counterparty/Store/RetailPoint/Employee/UoM.
|
||||
- [ ] **Документы (110k) в хронологии** — НЕ начато (основной объём; дизайн — migration-design.md).
|
||||
3. [x] **Новая орг на стенде** «FOOD MARKET» (простой режим), admin `foodmarket-migr@food-market.local`.
|
||||
Демо/«Магазин у дома» не тронуты. Токен МойСклада задан в настройках орг.
|
||||
4. [ ] Сверка → `docs/migration-reconciliation.md` (цели собраны в source-inventory; после переноса документов).
|
||||
|
|
|
|||
|
|
@ -135,46 +135,6 @@ public async Task<ActionResult<object>> ImportCounterparties([FromBody] ImportRe
|
|||
return Ok(new { jobId = job.Id });
|
||||
}
|
||||
|
||||
[HttpGet("jobs/{id:guid}")]
|
||||
public async Task<ActionResult<object>> JobStatus(Guid id, CancellationToken ct)
|
||||
{
|
||||
var j = await _db.ImportJobs.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (j is null) return NotFound();
|
||||
return Ok(new { j.Kind, status = j.Status.ToString(), j.Stage, j.Total, j.Created, j.Updated, j.Skipped, j.Message, j.StartedAt, j.FinishedAt });
|
||||
}
|
||||
|
||||
[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(
|
||||
ImportJobProgress job,
|
||||
Func<MoySkladImportService, ImportJobProgress, CancellationToken, Task> work,
|
||||
|
|
|
|||
|
|
@ -433,14 +433,12 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|||
var baseUrl = builder.Configuration["MoySklad:BaseUrl"];
|
||||
if (!string.IsNullOrWhiteSpace(baseUrl))
|
||||
http.BaseAddress = new Uri(baseUrl);
|
||||
http.Timeout = TimeSpan.FromSeconds(130); // страница каталога/документов иногда > 100с
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
{
|
||||
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
|
||||
});
|
||||
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
|
||||
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladDocumentImportService>();
|
||||
builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();
|
||||
|
||||
// Inventory
|
||||
|
|
|
|||
|
|
@ -27,6 +27,4 @@ public class Counterparty : TenantEntity
|
|||
/// (см. CounterpartiesController и InvoicesController.Apply, порт
|
||||
/// sklad 06.10 §11).</summary>
|
||||
public bool IsPlaceholder { get; set; }
|
||||
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||
public string? MoySkladExternalId { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,4 @@ public class RetailPoint : TenantEntity
|
|||
public string? FiscalSerial { get; set; } // серийный номер ККМ (заполнится когда подключим фискализацию)
|
||||
public string? FiscalRegNumber { get; set; } // регистрационный номер в ОФД
|
||||
public bool IsActive { get; set; } = true;
|
||||
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||
public string? MoySkladExternalId { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,4 @@ public class Store : TenantEntity
|
|||
public string? ManagerName { get; set; }
|
||||
public bool IsMain { get; set; } // основной склад организации
|
||||
public bool IsActive { get; set; } = true;
|
||||
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||
public string? MoySkladExternalId { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,4 @@ 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;
|
||||
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||
public string? MoySkladExternalId { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,9 +37,6 @@ public class InventoryDoc : TenantEntity, IVersionedEntity
|
|||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||
public string? MoySkladExternalId { get; set; }
|
||||
|
||||
public DateTime? PostedAt { get; set; }
|
||||
public Guid? PostedByUserId { get; set; }
|
||||
|
||||
|
|
|
|||
|
|
@ -56,8 +56,6 @@ public class Loss : TenantEntity
|
|||
public Guid? PostedByUserId { get; set; }
|
||||
|
||||
public ICollection<LossLine> Lines { get; set; } = new List<LossLine>();
|
||||
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||
public string? MoySkladExternalId { get; set; }
|
||||
}
|
||||
|
||||
public class LossLine : TenantEntity
|
||||
|
|
|
|||
|
|
@ -82,8 +82,6 @@ public class CashShift : TenantEntity, IVersionedEntity
|
|||
public string? Notes { get; set; }
|
||||
|
||||
public ICollection<CashDrawerOperation> DrawerOperations { get; set; } = new List<CashDrawerOperation>();
|
||||
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||
public string? MoySkladExternalId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Направление ящикового движения кассы. In — кассир положил
|
||||
|
|
|
|||
|
|
@ -131,8 +131,6 @@ public class MoneyTransaction : TenantEntity, IVersionedEntity
|
|||
public Guid? PostedByUserId { get; set; }
|
||||
|
||||
public ICollection<MoneyTransactionLink> Links { get; set; } = new List<MoneyTransactionLink>();
|
||||
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||
public string? MoySkladExternalId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Один <see cref="MoneyTransaction"/> ↔ один первичный документ с
|
||||
|
|
|
|||
|
|
@ -53,8 +53,6 @@ public class Employee : TenantEntity
|
|||
/// Брутфорс-защита (5 неверных → блок 5 мин) — на уровне точки (in-memory),
|
||||
/// т.к. вход по PIN без логина не идентифицирует конкретного сотрудника.</summary>
|
||||
public string? PinHash { get; set; }
|
||||
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||
public string? MoySkladExternalId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Привязка сотрудника к кассе (для роли Кассир): к каким RetailPoint'ам
|
||||
|
|
|
|||
|
|
@ -44,8 +44,6 @@ public class Enter : TenantEntity
|
|||
public Guid? PostedByUserId { get; set; }
|
||||
|
||||
public ICollection<EnterLine> Lines { get; set; } = new List<EnterLine>();
|
||||
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||
public string? MoySkladExternalId { get; set; }
|
||||
}
|
||||
|
||||
public class EnterLine : TenantEntity
|
||||
|
|
|
|||
|
|
@ -45,8 +45,6 @@ public class SupplierReturn : TenantEntity
|
|||
public decimal PayedSum { get; set; }
|
||||
|
||||
public ICollection<SupplierReturnLine> Lines { get; set; } = new List<SupplierReturnLine>();
|
||||
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||
public string? MoySkladExternalId { get; set; }
|
||||
}
|
||||
|
||||
public class SupplierReturnLine : TenantEntity
|
||||
|
|
|
|||
|
|
@ -45,9 +45,6 @@ public class Supply : TenantEntity, IVersionedEntity
|
|||
/// 0<PayedSum<Total — частично, PayedSum>=Total — оплачена.</summary>
|
||||
public decimal PayedSum { get; set; }
|
||||
|
||||
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||
public string? MoySkladExternalId { get; set; }
|
||||
|
||||
public ICollection<SupplyLine> Lines { get; set; } = new List<SupplyLine>();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,8 +121,6 @@ public class RetailSale : TenantEntity, IVersionedEntity
|
|||
/// если в настройках организации потом поменять провайдера, старые
|
||||
/// чеки помнят, кто их регистрировал.</summary>
|
||||
public int? FiscalProviderKind { get; set; }
|
||||
/// <summary>Id сущности в МойСкладе — для идемпотентного переноса истории.</summary>
|
||||
public string? MoySkladExternalId { get; set; }
|
||||
}
|
||||
|
||||
public class RetailSaleLine : TenantEntity
|
||||
|
|
|
|||
|
|
@ -135,55 +135,6 @@ public async Task<List<MsProductFolder>> GetAllFoldersAsync(string token, Cancel
|
|||
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>(
|
||||
string token,
|
||||
string path,
|
||||
|
|
|
|||
|
|
@ -1,160 +0,0 @@
|
|||
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();
|
||||
|
||||
// expand=positions (НЕ positions.assortment — это раскрывает весь товар и делает
|
||||
// страницу гигантской/зависающей). assortment.meta.href в позиции есть и так — из него
|
||||
// берём id товара.
|
||||
await foreach (var d in _client.StreamDocumentsAsync<MsDocument>(token, "entity/supply",
|
||||
"positions,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,
|
||||
};
|
||||
// Агрегируем позиции по товару (один и тот же товар в документе — суммируем
|
||||
// кол-во, цена — средневзвешенная): иначе два движения по одному товару дадут
|
||||
// дубликат строки stocks в рамках одного SaveChanges.
|
||||
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);
|
||||
}
|
||||
foreach (var (pid, agg) in byProduct)
|
||||
{
|
||||
var unit = agg.Qty != 0 ? decimal.Round(agg.Amount / agg.Qty, 4) : 0m;
|
||||
supply.Lines.Add(new foodmarket.Domain.Purchases.SupplyLine
|
||||
{
|
||||
OrganizationId = orgId, ProductId = pid, Quantity = agg.Qty, UnitPrice = unit,
|
||||
LineTotal = decimal.Round(agg.Amount, 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);
|
||||
|
||||
// Сохраняем ПО ОДНОМУ документу: следующий документ увидит уже
|
||||
// закоммиченные строки stocks (ApplyMovementAsync ищет в БД) — без дублей.
|
||||
await _db.SaveChangesAsync(ct);
|
||||
_db.ChangeTracker.Clear(); // не копим tracked-граф на 7k документов
|
||||
done.Add(d.Id);
|
||||
progress.Created++;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
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,34 +143,3 @@ public class MsCounterparty
|
|||
[JsonPropertyName("archived")] public bool Archived { 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 decimal Sum { get; set; } // тиыны (может прийти как 900000.0)
|
||||
[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 decimal CashSum { get; set; }
|
||||
[JsonPropertyName("noCashSum")] public decimal 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 decimal 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,11 +84,6 @@ 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;
|
||||
|
|
@ -104,15 +99,10 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
|||
|
||||
try
|
||||
{
|
||||
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 (existingByName.TryGetValue(c.Name, out var existing))
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
|
@ -120,7 +110,6 @@ 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++;
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
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